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
package/src/index.tsx CHANGED
@@ -1,2120 +1,1035 @@
1
- /** @jsxImportSource @opentui/solid */
2
-
3
- import type { JSX } from "@opentui/solid"
4
- import type {
5
- TuiPlugin,
6
- TuiPluginApi,
7
- TuiSlotContext,
8
- TuiSlotPlugin,
9
- TuiPluginModule,
10
- TuiThemeCurrent,
11
- TuiDialogStack,
12
- TuiPromptRef,
13
- SequenceBindingLike,
14
- } from "@opencode-ai/plugin/tui"
15
- import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
16
- import type {
17
- Part,
18
- TextPart,
19
- ToolPart,
20
- FilePart,
21
- ReasoningPart,
22
- } from "@opencode-ai/sdk/v2"
23
- import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
24
- import { PLUGIN_VERSION } from "./_version"
25
- import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
26
- import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
27
-
28
- // ---------------------------------------------------------------------------
29
- // Helpers
30
- // ---------------------------------------------------------------------------
31
-
32
- // Bun / Node globals — available at runtime in the OpenCode TUI process
33
- declare const process: { env: Record<string, string | undefined> } | undefined
34
-
35
- // ── terminal-width helpers ────────────────────────────────────────
36
- // CJK characters occupy 2 terminal columns; padEnd/padStart count
37
- // string length (=1 per char), which breaks alignment with mixed text.
38
-
39
- function charColumns(c: string): number {
40
- const code = c.codePointAt(0) ?? 0
41
- if (code < 0x20) return 0 // control
42
- if (code < 0x7F) return 1 // ASCII
43
- if (code < 0xA0) return 0 // C1 controls
44
- // East-Asian wide / fullwidth ranges
45
- if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
46
- (code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
47
- (code >= 0xAC00 && code <= 0xD7A3) || // Hangul
48
- (code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
49
- (code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
50
- (code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
51
- (code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
52
- (code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
53
- (code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
54
- return 2
55
- return 1
56
- }
57
-
58
- function visualWidth(s: string): number {
59
- let w = 0; for (const c of s) w += charColumns(c); return w
60
- }
61
-
62
- function visualPadEnd(s: string, cols: number): string {
63
- const pad = cols - visualWidth(s)
64
- return pad > 0 ? s + " ".repeat(pad) : s
65
- }
66
-
67
- /** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
68
- function truncateVisual(s: string, maxCols: number): string {
69
- if (visualWidth(s) <= maxCols) return s
70
- let result = "", w = 0
71
- for (const c of s) {
72
- const cw = charColumns(c)
73
- if (w + cw > maxCols - 1) { result += "\u2026"; break }
74
- result += c; w += cw
75
- }
76
- return result
77
- }
78
-
79
- // ── language ──────────────────────────────────────────────────────
80
- // 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 → 否则按系统 locale 自动检测。
81
- // 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
82
-
83
- const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
84
- const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
85
- ? (DEBUG_LANG as LangCode)
86
- : detectLang()
87
-
88
- // ── color helpers ────────────────────────────────────────────────
89
-
90
- /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
91
- function rgb(raw: unknown): { r: number; g: number; b: number } | null {
92
- if (typeof raw === "string" && raw.startsWith("#")) {
93
- const h = raw.slice(1)
94
- return {
95
- r: parseInt(h.slice(0, 2), 16),
96
- g: parseInt(h.slice(2, 4), 16),
97
- b: parseInt(h.slice(4, 6), 16),
98
- }
99
- }
100
- if (raw && typeof raw === "object") {
101
- const o = raw as Record<string, unknown>
102
- if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
103
- // RGBA channels may be 0-1 floats; detect and upscale.
104
- const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
105
- return {
106
- r: Math.round(o.r * scale),
107
- g: Math.round(o.g * scale),
108
- b: Math.round(o.b * scale),
109
- }
110
- }
111
- }
112
- return null
113
- }
114
-
115
- /** HSL saturation of an RGB color (0–1). */
116
- function saturation(r: number, g: number, b: number): number {
117
- const max = Math.max(r, g, b) / 255
118
- const min = Math.min(r, g, b) / 255
119
- const delta = max - min
120
- if (delta === 0) return 0
121
- const L = (max + min) / 2
122
- return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
123
- }
124
-
125
- /**
126
- * If the colour's saturation exceeds `maxSat`, pull it toward grey
127
- * until saturation drops to maxSat. Returns a hex string.
128
- */
129
- function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
130
- const c = rgb(raw)
131
- if (!c) return fallback
132
- const sat = saturation(c.r, c.g, c.b)
133
- if (sat <= maxSat) {
134
- // already muted — return as hex
135
- return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
136
- }
137
- /**
138
- * Binary search for the optimal grey-mix ratio α (0…1).
139
- *
140
- * 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
141
- * channels are only 0–255 (8 bit), so 8 iterations (1/256) would
142
- * technically suffice; 12 is intentionally over-budget — the extra
143
- * precision costs almost nothing and guarantees the saturation probe
144
- * converges to within a fraction of an 8‑bit step, eliminating
145
- * colour banding in edge cases.
146
- */
147
- // Bt.601 luma (perceptual brightness used as the grey anchor)
148
- const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
149
- let lo = 0, hi = 1
150
- for (let i = 0; i < 12; i++) {
151
- const mid = (lo + hi) / 2
152
- const nr = Math.round(c.r + (luma - c.r) * mid)
153
- const ng = Math.round(c.g + (luma - c.g) * mid)
154
- const nb = Math.round(c.b + (luma - c.b) * mid)
155
- if (saturation(nr, ng, nb) > maxSat) lo = mid
156
- else hi = mid
157
- }
158
- const nr = Math.round(c.r + (luma - c.r) * hi)
159
- const ng = Math.round(c.g + (luma - c.g) * hi)
160
- const nb = Math.round(c.b + (luma - c.b) * hi)
161
- return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
162
- }
163
-
164
- /** Darken a hex colour by multiplying each channel by `factor` (0–1). */
165
- function dimColor(hex: string, factor = 0.5): string {
166
- const c = rgb(hex)
167
- if (!c) return hex
168
- const r = Math.round(c.r * factor)
169
- const g = Math.round(c.g * factor)
170
- const b = Math.round(c.b * factor)
171
- return "#" + [r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
172
- }
173
-
174
- // Morandi fallbacks — used when a theme colour cannot be resolved
175
- const FALLBACK = {
176
- primary: "#8B9DAF",
177
- text: "#C5C5BB",
178
- muted: "#7A7A72",
179
- success: "#9CAF8B",
180
- warning: "#C5B88D",
181
- error: "#B08A8A",
182
- border: "#6B6B63",
183
- } as const
184
-
185
- /**
186
- * Desaturation ceiling for the Morandi-style palette.
187
- *
188
- * Morandi colours float around 0.15–0.30 saturation in HSL space.
189
- * 0.28 sits near the upper end of that range: it strips the aggressive
190
- * punch from high-saturation themes (Dracula, Solarized …) while
191
- * preserving enough colour identity that green / orange / red hit-rate
192
- * coding stays distinguishable.
193
- *
194
- * Lower → more grey, harder to tell colours apart.
195
- * Higher bright themes bleed through and defeat the muted look.
196
- */
197
- const MAX_SAT = 0.28
198
-
199
- function progressBar(percent: number, width: number): string {
200
- const clamped = Math.max(0, Math.min(100, percent))
201
- const filled = Math.round((clamped / 100) * width)
202
- const empty = Math.max(0, width - filled)
203
- return "\u2588".repeat(filled) + "\u2591".repeat(empty)
204
- }
205
-
206
- function fmt(n: number): string {
207
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
208
- if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
209
- return n.toLocaleString("en-US")
210
- }
211
-
212
- function num(v: unknown): number {
213
- return typeof v === "number" && Number.isFinite(v) ? v : 0
214
- }
215
-
216
- function fmtCost(n: number, symbol = "$", rate = 1): string {
217
- const v = n * rate
218
- if (v >= 1) return symbol + v.toFixed(2)
219
- if (v >= 0.01) return symbol + v.toFixed(3)
220
- return symbol + v.toFixed(4)
221
- }
222
-
223
- // ── token estimation ──
224
- // Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
225
- // chars per token) work well for natural language but systematically
226
- // under-count tokens in JSON and source code where every punctuation mark
227
- // tends to be its own token. Detect these cases and tighten the ratio.
228
- // See: GPT-4 / Claude tokenizer behaviour with structured text.
229
-
230
- function estimateTokens(text: string): number {
231
- if (!text || text.length === 0) return 0
232
- let ascii = 0
233
- let cjk = 0
234
- for (const c of text) {
235
- const code = c.codePointAt(0) ?? 0
236
- if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
237
- else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
238
- else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
239
- else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
240
- else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
241
- else ascii++
242
- }
243
-
244
- // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
245
- // ASCII chars/token for both JSON and source code — close to prose.
246
- // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
247
- // payloads, and systematically over-estimated token counts.
248
- const trimmed = text.trimStart()
249
- // Strip markdown code-fence prefix so that ```json … is detected as JSON
250
- const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
251
- const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
252
- && /"[^"]+"\s*:/.test(text)
253
- const codeLike = !jsonLike
254
- && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
255
-
256
- const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
257
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
258
- }
259
-
260
- interface TokenDist {
261
- system: number // UserMessage.system
262
- user: number // user message text/file parts
263
- agent: number // task tool input prompt/description (sub-agent delegation)
264
- toolCall: number // ToolPart.input (actual tool params)
265
- toolResult: number // ToolPart completed output / error
266
- output: number // AssistantMessage.tokens.output (API exact, reasoning excluded)
267
- reasoning: number // AssistantMessage.tokens.reasoning (API exact)
268
- apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
269
- apiInput: number // API exact total input context (input + cache read + cache write)
270
- stepCost: number // last step-finish part cost (USD) in the current round
271
- stepCount: number // step-finish parts count across the current round (parentID chain)
272
- }
273
-
274
- // ---------------------------------------------------------------------------
275
- // Balance state
276
- // ---------------------------------------------------------------------------
277
-
278
- interface BalanceState {
279
- status: "idle" | "loading" | "ok" | "error"
280
- data: BalanceEntry[] | null
281
- lastFetch: number
282
- error?: string
283
- key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换
284
- }
285
-
286
- const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes
287
-
288
- /**
289
- * 将余额从来源币种换算为目标币种。
290
- * DEFAULT_RATES USD=1 为基准:先折算为 USD,再换算到目标币种。
291
- */
292
- function convertBalance(target: string, targetRate: number, amount: number, from: string): number {
293
- if (from === target) return amount
294
- const fromRate = DEFAULT_RATES[from] ?? 1
295
- const usd = from === "USD" ? amount : amount / fromRate
296
- return target === "USD" ? usd : usd * targetRate
297
- }
298
-
299
- /**
300
- * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
301
- * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。
302
- * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。
303
- * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
304
- */
305
- function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
306
- try {
307
- const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
308
- // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cnmoonshot)
309
- const id = provider.id.toLowerCase()
310
- const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
311
- if (!hit) return ""
312
- const k = typeof hit.key === "string" ? hit.key : ""
313
- if (k) return k
314
- return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : ""
315
- } catch {
316
- return ""
317
- }
318
- }
319
-
320
- /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */
321
- function balanceSymbol(currency: string): string {
322
- const sym = CURRENCIES[currency]
323
- return sym ?? currency + " "
324
- }
325
-
326
- /** 紧凑数字缩写(底部状态栏用):1234 "1.2K",1234567 "1.2M"。 */
327
- function fmtCompact(n: number): string {
328
- if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
329
- if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
330
- return String(Math.round(n))
331
- }
332
-
333
- /** 余额数值格式化:≥1 或 0 显示固定 2 位小数;小额(<1)保留精度(最多 6 位),避免抹成 0.00。 */
334
- function formatBalanceAmount(total: string): string {
335
- const n = parseFloat(total)
336
- if (!Number.isFinite(n)) return total
337
- if (n === 0 || n >= 1) return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
338
- return n.toLocaleString("en-US", { maximumFractionDigits: 6 })
339
- }
340
-
341
- /**
342
- * 将余额列表格式化为单行文本。
343
- * 优先直接显示偏好币种(CNY/USD…);偏好币种为换算币种时按汇率折算第一条余额。
344
- */
345
- function formatBalanceText(list: BalanceEntry[], pref: string, rate: number): string {
346
- const native = pref ? list.find((x) => x.currency === pref) : undefined
347
- if (native) return balanceSymbol(native.currency) + formatBalanceAmount(native.total)
348
- const base = list[0]
349
- const baseAmt = parseFloat(base.total)
350
- const converted = Number.isFinite(baseAmt)
351
- ? convertBalance(pref || base.currency, rate, baseAmt, base.currency)
352
- : baseAmt
353
- const shown = pref && base.currency !== pref
354
- ? converted.toLocaleString("en-US", { maximumFractionDigits: 2 })
355
- : formatBalanceAmount(base.total)
356
- return balanceSymbol(pref || base.currency) + shown
357
- }
358
-
359
- // ---------------------------------------------------------------------------
360
- // Sidebar component
361
- // ---------------------------------------------------------------------------
362
-
363
- /** Signals shared between the TUI component and slash commands.
364
- * Created in the `tui` function scope so they do not survive module reload —
365
- * the component re-creates them on mount and restores user config from kv. */
366
- interface PanelSignals {
367
- currencySymbol: () => string
368
- setCurrencySymbol: (v: string) => void
369
- exchangeRate: () => number
370
- setExchangeRate: (v: number) => void
371
- langCode: () => LangCode
372
- setLangCode: (v: LangCode) => void
373
- sectionDetail: () => boolean
374
- setSectionDetail: (v: boolean) => void
375
- sectionModel: () => boolean
376
- setSectionModel: (v: boolean) => void
377
- sectionDist: () => boolean
378
- setSectionDist: (v: boolean) => void
379
- sectionSkills: () => boolean
380
- setSectionSkills: (v: boolean) => void
381
- sectionBalance: () => boolean
382
- setSectionBalance: (v: boolean) => void
383
- /** Bottom status bar (prompt hint line) visibility. */
384
- sectionBottom: () => boolean
385
- setSectionBottom: (v: boolean) => void
386
- /** Increment to force a balance re-fetch. */
387
- balanceRefresh: () => number
388
- setBalanceRefresh: (v: number) => void
389
- /** Currently selected balance provider id (e.g. "deepseek"). */
390
- balanceProviderId: () => string
391
- setBalanceProviderId: (v: string) => void
392
- /** Auto-switch to the session's provider for balance display. Manual switch disables it. */
393
- autoBalance: () => boolean
394
- setAutoBalance: (v: boolean) => void
395
- /** True when the session's provider has no balance adapter (auto mode). Suppresses balance polling. */
396
- balanceUnsupported: () => boolean
397
- setBalanceUnsupported: (v: boolean) => void
398
- /** Shared balance query state single source of truth for sidebar and bottom bar. */
399
- balanceState: () => BalanceState
400
- /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */
401
- balanceCurrency: () => string
402
- setBalanceCurrency: (v: string) => void
403
- borderVisible: () => boolean
404
- setBorderVisible: (v: boolean) => void
405
- /** When set, the panel renders stats for this session instead of the main one. */
406
- overrideSessionId: () => string | undefined
407
- setOverrideSessionId: (v: string | undefined) => void
408
- /** True while our sidebar panel is mounted — host sidebar is visible (occupies 42 cols). */
409
- sidebarVisible: () => boolean
410
- setSidebarVisible: (v: boolean) => void
411
- }
412
-
413
- const CURRENCIES: Record<string, string> = {
414
- USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
415
- }
416
- /** Approximate USD exchange rates — used as defaults when switching currency.
417
- * Users can override via /cache-rate. Last updated 2026-05. */
418
- const DEFAULT_RATES: Record<string, number> = {
419
- USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
420
- }
421
-
422
- const MIN_PANEL_WIDTH = 20
423
- const DEFAULT_PANEL_WIDTH = 26
424
-
425
- /** ── layout measurement constants (visual columns) ── */
426
- const LABEL_GAP = 1 // label(如 "Hit")后面的空格
427
- const BAR_BRACKETS = 2 // "[" + "]" 包围进度条
428
- const BAR_GAP = 1 // "]" 后面的空格
429
- const PCT_FIXED_WIDTH = 5 // "XX.X%" 固定 5 字符宽度
430
- const HEADER_PREFIX = 2 // 折叠态标题行:▶/▼ 图标 + 后面的空格
431
- const UNIT_GAP = 1 // 计量单位前的空格(如 "tok"
432
-
433
-
434
- function TokenCachePanel(props: {
435
- theme: TuiThemeCurrent
436
- api: TuiPluginApi
437
- sessionId: string
438
- signals: PanelSignals
439
- }): JSX.Element {
440
- const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
441
- const [open, setOpen] = createSignal(true)
442
- const [detailOpen, setDetailOpen] = createSignal(true)
443
- const [modelOpen, setModelOpen] = createSignal(true)
444
- const [distOpen, setDistOpen] = createSignal(false)
445
- const [skillsOpen, setSkillsOpen] = createSignal(true)
446
- let boxEl: any
447
-
448
- // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
449
- createEffect(() => {
450
- props.signals.setSidebarVisible(true)
451
- onCleanup(() => props.signals.setSidebarVisible(false))
452
- })
453
-
454
- // ── shared signals (de-structured so internal code is unchanged) ──
455
- const {
456
- currencySymbol, setCurrencySymbol,
457
- exchangeRate, setExchangeRate,
458
- langCode,
459
- sectionDetail, setSectionDetail,
460
- sectionModel, setSectionModel,
461
- sectionDist, setSectionDist,
462
- sectionSkills, setSectionSkills,
463
- sectionBalance, setSectionBalance,
464
- balanceRefresh,
465
- balanceProviderId, setBalanceProviderId,
466
- autoBalance, setAutoBalance,
467
- balanceUnsupported, setBalanceUnsupported,
468
- balanceState,
469
- balanceCurrency, setBalanceCurrency,
470
- borderVisible, setBorderVisible,
471
- } = props.signals
472
-
473
- // ── reactive translation (follows langCode signal) ──
474
- const t = createT(() => langCode())
475
-
476
- // ── scan session messages reactively ──
477
- // SolidJS createMemo re-evaluates whenever the underlying
478
- // api.state.session state changes — no event listener needed.
479
-
480
- // ── distribution cache ────────────────────────────────────────
481
- // When data() re-computes before api.state.part() is warm (e.g. after
482
- // a view switch), hasDistData flips to false and the distribution
483
- // block disappears. Keep the last valid snapshot so the UI stays
484
- // stable until the next successful computation arrives.
485
- const [lastDist, setLastDist] = createSignal<TokenDist>({
486
- system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
487
- output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0,
488
- })
489
- const [lastHasDist, setLastHasDist] = createSignal(false)
490
-
491
- const [dataSignal, setDataSignal] = createSignal<any>({
492
- hitRate: 0, read: 0, write: 0, freshInput: 0, output: 0,
493
- cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
494
- hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
495
- providerName: "", sessionHitRate: 0,
496
- dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, reasoning: 0, apiOutput: 0, apiInput: 0, stepCost: 0, stepCount: 0 },
497
- hasDistData: false,
498
- skills: [] as { name: string; tokens: number }[],
499
- hasSkills: false,
500
- })
501
- const [refreshTick, setRefreshTick] = createSignal(0)
502
-
503
- // 当前 provider 显示名(余额查询状态为共享信号,见 PanelSignals.balanceState)
504
- const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name)
505
-
506
- // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。
507
- // 直接追踪 messages 取最后一条 assistant 消息的 providerID——
508
- // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。
509
- createEffect(() => {
510
- if (!autoBalance()) return
511
- const sid = props.signals.overrideSessionId() ?? props.sessionId
512
- const msgs = props.api.state.session.messages(sid) as Message[]
513
- let pid = ""
514
- for (let i = msgs.length - 1; i >= 0; i--) {
515
- const m = msgs[i]
516
- if (m.role === "assistant" && (m as AssistantMessage).providerID) {
517
- pid = (m as AssistantMessage).providerID
518
- break
519
- }
520
- }
521
- // 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载)
522
- // 回退到会话级模型元数据,反映当前正在使用的 provider
523
- if (!pid) {
524
- try {
525
- const session = props.api.state.session.get(sid)
526
- pid = session?.model?.providerID ?? ""
527
- } catch { /* ignore */ }
528
- }
529
- if (!pid) return
530
- const hit = matchBalanceProvider(pid)
531
- if (hit) {
532
- setBalanceUnsupported(false)
533
- if (hit.id !== balanceProviderId()) {
534
- setBalanceProviderId(hit.id)
535
- props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
536
- }
537
- } else {
538
- // 当前提供商没有余额适配器 标记不支持,余额显示 N/A 并停止轮询
539
- setBalanceUnsupported(true)
540
- }
541
- })
542
-
543
- // ── auto-clear override when the user navigates to a different main session ──
544
- let lastMainSid = props.sessionId
545
- createEffect(() => {
546
- const sid = props.sessionId
547
- if (sid !== lastMainSid) {
548
- lastMainSid = sid
549
- if (props.signals.overrideSessionId()) {
550
- props.signals.setOverrideSessionId(undefined)
551
- props.api.kv.set(`${KV_PREFIX}.session`, "")
552
- }
553
- }
554
- })
555
-
556
- createEffect(() => {
557
- const sid = props.signals.overrideSessionId() ?? props.sessionId
558
- void refreshTick()
559
- void partVersion()
560
-
561
- // 自然追踪 messages provider(SDK 数据就绪时自动重新执行)
562
- const msgs = props.api.state.session.messages(sid) as Message[]
563
- const session = typeof props.api.state.session.get === "function"
564
- ? props.api.state.session.get(sid)
565
- : undefined
566
-
567
- // 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
568
- // 若字段不存在(旧版本 SDK),降级到消息遍历累加
569
- let input = session?.tokens?.input ?? 0
570
- let read = session?.tokens?.cache?.read ?? 0
571
- let write = session?.tokens?.cache?.write ?? 0
572
- let output = session?.tokens?.output ?? 0
573
- let cost = session?.cost ?? 0
574
- let pid = session?.model?.providerID ?? ""
575
- let mid = session?.model?.id ?? ""
576
-
577
- const fallbackTokens = session?.tokens == null
578
- const fallbackCost = session?.cost == null
579
- const fallbackModel = !pid || !mid
580
-
581
- let prevMsgHitRate = -1, lastMsgHitRate = -1
582
- for (const msg of msgs) {
583
- if (msg.role !== "assistant") continue
584
- const tok = (msg as AssistantMessage).tokens; if (!tok) continue
585
- const mit = num(tok.input) + num(tok.cache?.read) + num(tok.cache?.write), mrt = num(tok.cache?.read)
586
- if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
587
- if (fallbackTokens) {
588
- input += num(tok.input); read += num(tok.cache?.read); write += num(tok.cache?.write); output += num(tok.output)
589
- }
590
- if (fallbackCost) {
591
- cost += num((msg as AssistantMessage).cost)
592
- }
593
- if (fallbackModel && (msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
594
- pid = (msg as AssistantMessage).providerID; mid = (msg as AssistantMessage).modelID
595
- }
596
- }
597
- let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0
598
- if (read > 0 && pid && mid && Array.isArray(props.api.state.provider)) for (const provider of props.api.state.provider) {
599
- if (provider.id !== pid) continue
600
- const model = provider.models[mid]; if (!model?.cost) continue
601
- inputRate = num(model.cost.input); cacheReadRate = num(model.cost.cache?.read); cacheWriteRate = num(model.cost.cache?.write)
602
- if (inputRate > cacheReadRate) saved = (read * (inputRate - cacheReadRate)) / 1_000_000
603
- break
604
- }
605
- const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
606
- // 总命中率分母含缓存写(业界口径:read / (input+read+write))
607
- const freshTotal = input + read + write, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
608
- const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
609
- const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
610
- const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
611
-
612
- // untrack 只包裹已知触发死锁的 API
613
- const distData = untrack(() => {
614
- 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 }
615
- let hasDistData = false
616
- const loadedSkills = new Map<string, { name: string; tokens: number }>()
617
- try {
618
- const cfg = props.api.state.config as Record<string, unknown>
619
- const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
620
- const agents = cfg?.agent as Record<string, unknown> | undefined
621
- const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
622
- const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
623
- if (sysPrompt) dist.system = estimateTokens(sysPrompt)
624
- let lastAssMsg: AssistantMessage | undefined
625
- for (const msg of msgs) {
626
- if (msg.role === "user") {
627
- const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system)
628
- let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
629
- for (const p of parts) {
630
- if (p.type === "text" && !(p as any).synthetic && !(p as any).ignored) dist.user += estimateTokens((p as any).text)
631
- else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) }
632
- }
633
- } else if (msg.role === "assistant") {
634
- const am = msg as AssistantMessage
635
- dist.output += num(am.tokens?.output)
636
- dist.reasoning += num(am.tokens?.reasoning)
637
- let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
638
- for (const p of parts) {
639
- if (p.type === "tool") {
640
- const tp = p as any; let rawInput = ""
641
- try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
642
- if (rawInput) dist.toolCall += estimateTokens(rawInput)
643
- // 子代理委托(task 工具):任务描述计入子代理指令(1.15.x 无 subtask part)
644
- if (tp.tool === "task" && tp.state?.input) {
645
- const ti = tp.state.input
646
- const prompt = typeof ti.prompt === "string" ? ti.prompt : ""
647
- const desc = typeof ti.description === "string" ? ti.description : ""
648
- dist.agent += estimateTokens(prompt || desc)
649
- }
650
- if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
651
- else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
652
- if (tp.tool === "skill" && tp.state.status === "completed") {
653
- // TUI SDK strips tool metadata extract skill name from well-known output format.
654
- // Cross-validated against api.client.app.skills() when available.
655
- let name: string | undefined = tp.state.metadata?.name
656
- if (typeof name !== "string") {
657
- const m = typeof tp.state.output === "string"
658
- ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
659
- : null
660
- if (m) name = m[1].trim()
661
- }
662
- if (typeof name === "string") {
663
- const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0
664
- const existing = loadedSkills.get(name)
665
- if (!existing || existing.tokens < tokens) {
666
- loadedSkills.set(name, { name, tokens })
667
- }
668
- }
669
- }
670
- } else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
671
- }
672
- }
673
- }
674
- // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
675
- for (let i = msgs.length - 1; i >= 0; i--) {
676
- if (msgs[i].role !== "assistant") continue
677
- const tok = (msgs[i] as AssistantMessage).tokens
678
- if (tok && ((tok.input ?? 0) > 0 || (tok.cache?.read ?? 0) > 0 || (tok.cache?.write ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
679
- }
680
- // 取最后一条有数据消息的总输入(含缓存读/写)作为当前 context 大小
681
- dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read) + num(lastAssMsg?.tokens?.cache?.write)
682
- dist.apiOutput = num(lastAssMsg?.tokens?.output)
683
- // 本回合(最后一条有数据消息所在的 parentID 链)的 API 调用次数与末次成本。
684
- // opencode 将回合内每次工具调用循环拆为独立 assistant 消息(各含 1 个 step-finish),
685
- // 故按 parentID 链聚合统计,而非单条消息。
686
- if (lastAssMsg) {
687
- const roundParent = (lastAssMsg as AssistantMessage).parentID
688
- let lastCost: number | undefined
689
- for (let i = msgs.length - 1; i >= 0; i--) {
690
- const m = msgs[i]
691
- if (m.role !== "assistant") continue
692
- if ((m as AssistantMessage).parentID !== roundParent) break
693
- let parts: readonly Part[] = []; try { parts = props.api.state.part(m.id) } catch {}
694
- for (const p of parts) {
695
- if (p.type !== "step-finish") continue
696
- dist.stepCount++
697
- const sc = (p as { cost?: unknown }).cost
698
- if (lastCost === undefined && typeof sc === "number" && Number.isFinite(sc)) lastCost = sc
699
- }
700
- }
701
- if (lastCost !== undefined) dist.stepCost = lastCost
702
- }
703
- hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0 || dist.reasoning > 0
704
- } catch {}
705
- const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
706
- const skills = [...loadedSkills.values()]
707
- return { finalDist, finalHasDist, skills }
708
- })
709
-
710
- setDataSignal({
711
- hitRate, read, write, freshInput: input, output, cost, saved, model,
712
- inputRate, cacheReadRate, cacheWriteRate, hasPricing,
713
- hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
714
- trend, hasTrendData, providerName, sessionHitRate,
715
- dist: distData.finalDist, hasDistData: distData.finalHasDist,
716
- skills: distData.skills, hasSkills: distData.skills.length > 0,
717
- })
718
- })
719
-
720
- const data = createMemo(() => {
721
- return dataSignal()
722
- })
723
-
724
- // Persist the last valid distribution so that data() can fall back
725
- // to it while api.state.part() is re-hydrating after a view switch.
726
- createEffect(() => {
727
- const d = data()
728
- if (d.hasDistData) {
729
- setLastDist({ ...d.dist })
730
- setLastHasDist(true)
731
- // Also persist across component remounts (view switches)
732
- try { props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist }) } catch {}
733
- }
734
- })
735
-
736
- // ── token distribution (in-process via api.state.part) ──
737
- const [partVersion, setPartVersion] = createSignal(0)
738
-
739
- // Persist fold state to api.kv
740
- const KV_PREFIX = "cache_panel"
741
- const persistFold = (key: string, val: boolean) => {
742
- try { props.api.kv.set(`${KV_PREFIX}.${key}`, val) } catch {}
743
- }
744
-
745
- onMount(() => {
746
- // Reset panelWidth on (re)mount so the layout uses a clean
747
- // default until onSizeChange measures the live box dimensions.
748
- setPanelWidth(DEFAULT_PANEL_WIDTH)
749
-
750
- // Restore fold state from persisted storage (non-critical — fire and forget)
751
- try {
752
- setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)))
753
- setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
754
- setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
755
- setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
756
- setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
757
- } catch {}
758
-
759
- // Restore user config (currency, rate, section visibility).
760
- // Try synchronously first (kv is usually ready on mount), fall back to
761
- // polling if the module was reloaded and kv hasn't initialised yet.
762
- const doRestore = () => {
763
- try {
764
- const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
765
- const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
766
- if (typeof sym === "string") setCurrencySymbol(sym)
767
- if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
768
- const balCur = props.api.kv.get<string>(`${KV_PREFIX}.balance_currency`)
769
- if (typeof balCur === "string") setBalanceCurrency(balCur)
770
- // Restore balance provider (fall back to default when unknown)
771
- const savedProvider = props.api.kv.get<string>(`${KV_PREFIX}.balance.provider`)
772
- if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
773
- setBalanceProviderId(savedProvider)
774
- setBalanceUnsupported(false)
775
- }
776
- // Restore auto-switch (default on)
777
- const savedAuto = props.api.kv.get<boolean>(`${KV_PREFIX}.balance.auto`)
778
- if (typeof savedAuto === "boolean") setAutoBalance(savedAuto)
779
- // Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key)
780
- const legacyKey = props.api.kv.get<string>(`${KV_PREFIX}.ds_key`, "")
781
- if (legacyKey) {
782
- const dsKey = props.api.kv.get<string>(`${KV_PREFIX}.balance.deepseek.key`, "")
783
- if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey)
784
- props.api.kv.set(`${KV_PREFIX}.ds_key`, "")
785
- }
786
- // 恢复的 provider 可能与默认值不同,强制重新查询
787
- props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
788
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
789
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
790
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
791
- setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
792
- setSectionBalance(Boolean(props.api.kv.get(`${KV_PREFIX}.section.balance`, true)))
793
- const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
794
- setBorderVisible(bv !== false)
795
- // Restore distribution snapshot so the token distribution block
796
- // doesn't blank out while api.state.part() re-hydrates.
797
- const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
798
- if (cachedDist) {
799
- setLastDist(cachedDist)
800
- setLastHasDist(true)
801
- }
802
- } catch {
803
- // kv read failed — signals stay at defaults
804
- }
805
- // Re-measure panel width after config signals have settled
806
- if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
807
- setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width))
808
- }
809
- }
810
-
811
- if (props.api.kv.ready) {
812
- doRestore()
813
- } else {
814
- // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
815
- // on platforms where kv initialisation may be delayed (Linux single-thread
816
- // mode, session switch storms, etc.).
817
- const MAX_POLL = 100
818
- let tries = 0
819
- const pollRestore = () => {
820
- if (!props.api.kv.ready) {
821
- if (++tries > MAX_POLL) { doRestore(); return }
822
- setTimeout(pollRestore, 10)
823
- return
824
- }
825
- doRestore()
826
- }
827
- pollRestore()
828
- }
829
-
830
- // Debounce partVersion updates so that event bursts during session
831
- // switching / streaming don't cause data() to re-compute on every
832
- // single event (up to hundreds per second on Linux single-thread).
833
- let partTimer: ReturnType<typeof setTimeout> | undefined
834
- const bumpPartVersion = () => {
835
- clearTimeout(partTimer)
836
- partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100)
837
- }
838
- const unsubPart = props.api.event.on("message.part.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
839
- const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
840
- const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
841
- setRefreshTick(v => v + 1)
842
- onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
843
- })
844
-
845
- // ── colours ──
846
- // Pull from the current theme, auto-desaturate if too punchy,
847
- // fall back to Morandi when a key is missing from the theme.
848
- const pal = createMemo(() => {
849
- const t = props.theme as Record<string, unknown>
850
- const sat = (k: string, fb: string) => desaturateTo(t[k], MAX_SAT, fb)
851
- return {
852
- primary: sat("primary", FALLBACK.primary),
853
- text: sat("text", FALLBACK.text),
854
- muted: sat("textMuted", FALLBACK.muted),
855
- success: sat("success", FALLBACK.success),
856
- warning: sat("warning", FALLBACK.warning),
857
- error: sat("error", FALLBACK.error),
858
- border: sat("border", FALLBACK.border),
859
- }
860
- })
861
-
862
- const hitColor = createMemo(() => {
863
- const r = data().hitRate
864
- if (r >= 85) return pal().success
865
- if (r >= 70) return pal().warning
866
- return pal().error
867
- })
868
-
869
- /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
870
- const gutter = createMemo(() => borderVisible() ? 6 : 0)
871
-
872
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
873
- function trendLabel(t: number): string {
874
- // |t| < 0.05 视为无变化:避免显示 "↑0.0%" 的矛盾(箭头存在但数值截断为零)
875
- if (Math.abs(t) < 0.05) return "-"
876
- return (t > 0 ? "\u2191" : "\u2193") + Math.abs(t).toFixed(1) + "%"
877
- }
878
-
879
- const barW = createMemo(() => {
880
- const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
881
- const overhead = visualWidth(t("hit")) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
882
- return Math.max(3, panelWidth() - overhead)
883
- })
884
- const bar = createMemo(() => progressBar(data().hitRate, barW()))
885
- const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
886
-
887
- // When border visibility changes the box dimensions shift, which
888
- // may not reliably trigger onSizeChange across (re)mount cycles.
889
- // Force panelWidth to resync with the live box after every change.
890
- createEffect(() => {
891
- borderVisible()
892
- if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
893
- const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
894
- setPanelWidth((prev) => (prev === w ? prev : w))
895
- }
896
- })
897
-
898
- // left-align label, right-align value auto-fill space between
899
- const justify = (label: string, value: string, unit = ""): string => {
900
- const gauge = panelWidth() - gutter()
901
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
902
- const gap = Math.max(1, gauge - used)
903
- return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
904
- }
905
-
906
- return (
907
- <box
908
- border={borderVisible()}
909
- {...(borderVisible() ? { borderColor: pal().border } : {})}
910
- paddingTop={0}
911
- paddingBottom={0}
912
- paddingLeft={borderVisible() ? 2 : 0}
913
- paddingRight={borderVisible() ? 2 : 0}
914
- flexDirection="column"
915
- gap={0}
916
- ref={boxEl}
917
- onSizeChange={() => {
918
- // boxEl.width may be undefined before the first measurement — guard with 0
919
- const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH
920
- setPanelWidth((prev) => (prev === w ? prev : w))
921
- }}
922
- >
923
- {/* collapsible header */}
924
- <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
925
- <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
926
- <span style={{ fg: pal().primary }}>
927
- <b>{t("title")}</b>
928
- <Show when={open()}>
929
- <span style={{ fg: dimColor(pal().muted, 0.75) }}> v{PLUGIN_VERSION}</span>
930
- </Show>
931
- </span>
932
- <Show when={!open() && data().hasData}>
933
- <Show when={data().hasTrendData}>
934
- <span>
935
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded") + " " + trendLabel(data().trend))))}
936
- </span>
937
- <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
938
- <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
939
- {" "}{trendLabel(data().trend)}
940
- </span>
941
- </Show>
942
- <Show when={!data().hasTrendData}>
943
- <span>
944
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t("title")) - visualWidth(pct() + " " + t("hitFolded"))))}
945
- </span>
946
- <span style={{ fg: hitColor() }}>{pct()} {t("hitFolded")}</span>
947
- </Show>
948
- </Show>
949
- </text>
950
-
951
- <Show when={open()}>
952
- <Show when={props.signals.overrideSessionId()}>
953
- {(() => {
954
- const prefix = " \u21b3 " + t("subPrefix")
955
- const maxSidW = Math.max(6, panelWidth() - visualWidth(prefix))
956
- return (
957
- <text>
958
- <span style={{ fg: pal().muted }}>{prefix}</span>
959
- <span style={{ fg: pal().text }}>{truncateVisual(props.signals.overrideSessionId()!, maxSidW)}</span>
960
- </text>
961
- )
962
- })()}
963
- </Show>
964
- <Show when={data().hasData} fallback={
965
- <>
966
- <text fg={pal().muted}>{sep()}</text>
967
- <text>
968
- <span style={{ fg: pal().muted }}>{"> "}</span>
969
- <span style={{ fg: pal().muted }}>{t("noData")}</span>
970
- </text>
971
- </>
972
- }>
973
- <text fg={pal().muted}>{sep()}</text>
974
-
975
- {/* hit rate + bar inline to avoid box spacing */}
976
- <text>
977
- <span style={{ fg: pal().text }}>{t("hit")} </span>
978
- <span style={{ fg: hitColor() }}>[{bar()}] </span>
979
- <span style={{ fg: pal().text }}>{pct()}</span>
980
- <Show when={data().hasTrendData}>
981
- <span style={{ fg: Math.abs(data().trend) >= 0.05 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
982
- {" "}{trendLabel(data().trend)}
983
- </span>
984
- </Show>
985
- </text>
986
-
987
- {/* session cumulative hit rate */}
988
- <text fg={pal().muted}>
989
- {justify(t("totalHit"), (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
990
- </text>
991
-
992
- {/* ── detail section (collapsible, default open) ── */}
993
- <Show when={sectionDetail()}>
994
- <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
995
- <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
996
- <span style={{ fg: pal().primary }}><b>{t("secDetail")}</b></span>
997
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t("secDetail")))}</span>
998
- </text>
999
-
1000
- <Show when={detailOpen()}>
1001
- <Show when={data().read > 0}>
1002
- <text fg={pal().muted}>
1003
- {justify(t("read"), fmt(data().read), t("tok"))}
1004
- </text>
1005
- </Show>
1006
- <Show when={data().write > 0}>
1007
- <text fg={pal().muted}>
1008
- {justify(t("write"), fmt(data().write), t("tok"))}
1009
- </text>
1010
- </Show>
1011
- {/* 未命中 = 新鲜输入 + 缓存写(两者都未从缓存命中) */}
1012
- <text fg={pal().muted}>
1013
- {justify(t("miss"), fmt(data().freshInput + data().write), t("tok"))}
1014
- </text>
1015
- <text fg={pal().muted}>
1016
- {justify(t("out"), fmt(data().output), t("tok"))}
1017
- </text>
1018
- {/* 本回合多次 API 调用时才显示调用次数与末次成本(单次调用不占行) */}
1019
- <Show when={data().dist.stepCount >= 2}>
1020
- <text fg={pal().muted}>
1021
- {justify(t("stepsCount", { n: data().dist.stepCount }), fmtCost(data().dist.stepCost, currencySymbol(), exchangeRate()))}
1022
- </text>
1023
- </Show>
1024
- <Show when={data().saved > 0}>
1025
- <text>
1026
- <span style={{ fg: pal().muted }}>{t("saved")}</span>
1027
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t("saved")) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
1028
- <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
1029
- </text>
1030
- </Show>
1031
- </Show>
1032
- </Show>
1033
-
1034
- {/* ── model section (collapsible, default open) ── */}
1035
- <Show when={sectionModel()}>
1036
- {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
1037
- <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
1038
- <span style={{ fg: pal().primary }}><b>{t("secModel")}</b></span>
1039
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t("secModel")))}</span>
1040
- </text>}
1041
-
1042
- <Show when={modelOpen()}>
1043
- <text fg={pal().text}>
1044
- {justify(t("cost"), fmtCost(data().cost, currencySymbol(), exchangeRate()))}
1045
- </text>
1046
- <Show when={data().providerName}>
1047
- <text fg={pal().muted}>
1048
- {justify(t("provider"), data().providerName)}
1049
- </text>
1050
- </Show>
1051
- <text fg={pal().muted}>
1052
- {justify(t("model"), data().model)}
1053
- </text>
1054
- <Show when={data().hasPricing}>
1055
- <text fg={pal().muted}>
1056
- {justify(t("rate"), currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t("inputRate"))}
1057
- </text>
1058
- <Show when={data().cacheReadRate > 0}>
1059
- <text fg={pal().muted}>
1060
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t("cacheRate"))}
1061
- </text>
1062
- </Show>
1063
- <Show when={data().cacheWriteRate > 0}>
1064
- <text fg={pal().muted}>
1065
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t("writeRate"))}
1066
- </text>
1067
- </Show>
1068
- </Show>
1069
- </Show>
1070
- </Show>
1071
-
1072
- {/* ── token distribution (collapsible, default closed) ── */}
1073
- <Show when={sectionDist()}>
1074
- <Show when={data().hasDistData}>
1075
- {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
1076
- <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
1077
- <span style={{ fg: pal().primary }}><b>{t("distTitle")}</b></span>
1078
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t("distTitle")))}</span>
1079
- </text>}
1080
- <Show when={distOpen()}>
1081
- <Show when={data().dist.system > 0}>
1082
- <text fg={pal().muted}>
1083
- {justify(t("distSys"), fmt(data().dist.system), t("tok"))}
1084
- </text>
1085
- </Show>
1086
- <Show when={data().dist.user > 0}>
1087
- <text fg={pal().muted}>
1088
- {justify(t("distUser"), fmt(data().dist.user), t("tok"))}
1089
- </text>
1090
- </Show>
1091
- <Show when={data().dist.agent > 0}>
1092
- <text fg={pal().muted}>
1093
- {justify(t("distAgent"), fmt(data().dist.agent), t("tok"))}
1094
- </text>
1095
- </Show>
1096
- <Show when={data().dist.toolCall > 0}>
1097
- <text fg={pal().muted}>
1098
- {justify(t("distTool"), fmt(data().dist.toolCall), t("tok"))}
1099
- </text>
1100
- </Show>
1101
- <Show when={data().dist.toolResult > 0}>
1102
- <text fg={pal().muted}>
1103
- {justify(t("distRes"), fmt(data().dist.toolResult), t("tok"))}
1104
- </text>
1105
- </Show>
1106
- <Show when={data().dist.reasoning > 0}>
1107
- <text fg={pal().muted}>
1108
- {justify(t("distReason"), fmt(data().dist.reasoning), t("tok"))}
1109
- </text>
1110
- </Show>
1111
- </Show>
1112
- </Show>
1113
- </Show>
1114
-
1115
- {/* ── loaded skills (collapsible, default open) ── */}
1116
- <Show when={sectionSkills()}>
1117
- <Show when={data().hasSkills}>
1118
- {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
1119
- <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
1120
- <span style={{ fg: pal().primary }}><b>{t("secSkills")}</b></span>
1121
- <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
1122
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t("secSkills") + ` (${data().skills.length})`))}</span>
1123
- </text>}
1124
- <Show when={skillsOpen()}>
1125
- {data().skills.map((sk: { name: string; tokens: number }) => {
1126
- const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t("tok"))
1127
- const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
1128
- const label = truncateVisual(sk.name, maxLabel)
1129
- return (
1130
- <text fg={pal().muted}>
1131
- {justify(label, fmt(sk.tokens), t("tok"))}
1132
- </text>
1133
- )
1134
- })}
1135
- </Show>
1136
- </Show>
1137
- </Show>
1138
-
1139
- {/* ── provider balance (single line) ── */}
1140
- <Show when={sectionBalance()}>
1141
- <text fg={pal().muted}>{sep()}</text>
1142
- <Show when={balanceUnsupported()}>
1143
- <text fg={pal().muted}>
1144
- <span style={{ fg: pal().muted }}>{"> "}</span>
1145
- <span>{t("balUnsupported")}</span>
1146
- </text>
1147
- </Show>
1148
- <Show when={!balanceUnsupported()}>
1149
- <Show when={balanceState().status === "idle"}>
1150
- <text fg={pal().muted}>
1151
- <span style={{ fg: pal().muted }}>{"> "}</span>
1152
- <span>{t("balNoKey", { p: providerName() })}</span>
1153
- </text>
1154
- </Show>
1155
- <Show when={balanceState().status === "loading"}>
1156
- <text fg={pal().muted}>
1157
- <span style={{ fg: pal().muted }}>{"> "}</span>
1158
- <span>{t("balLoading")}</span>
1159
- </text>
1160
- </Show>
1161
- <Show when={balanceState().status === "error"}>
1162
- <text fg={pal().error}>
1163
- <span style={{ fg: pal().muted }}>{"> "}</span>
1164
- <span>{(() => {
1165
- const code = balanceState().error
1166
- if (code === "401") return t("balErr401")
1167
- if (code === "403") return t("balErr403")
1168
- if (code === "EMPTY") return t("balErrEmpty")
1169
- if (code === "TIMEOUT") return t("balErrTimeout")
1170
- return t("balError") + (code ? ` (${code})` : "")
1171
- })()}</span>
1172
- </text>
1173
- </Show>
1174
- <Show when={balanceState().status === "ok" && balanceState().data}>
1175
- <text fg={pal().text}>
1176
- {justify(t("balTotal"), formatBalanceText(balanceState().data!, balanceCurrency(), exchangeRate()))}
1177
- </text>
1178
- </Show>
1179
- </Show>
1180
- </Show>
1181
- </Show>
1182
- </Show>
1183
- </box>
1184
- )
1185
- }
1186
-
1187
- // ---------------------------------------------------------------------------
1188
- // Plugin entry
1189
- // ---------------------------------------------------------------------------
1190
-
1191
- /**
1192
- * 路径截断的固定开销(列数):仅宿主布局常量,不含任何内容宽度——
1193
- * 统计宽度、宿主 usage、commands 快捷键均为运行时动态计算。
1194
- * - marginLeft=1 + space-between 余量 ≈ 2 列
1195
- */
1196
- const PATH_CHROME = 2
1197
- /**
1198
- * 路径可用宽度低于此列数时整体隐藏(极窄终端下路径信息价值太低,
1199
- * 残留的 "E:\Work…" 反而挤压右侧统计与 commands,直接让位更干净)。
1200
- */
1201
- const HIDE_PATH_BELOW = 14
1202
-
1203
- /**
1204
- * 从宿主 keymap 动态读取命令的快捷键显示文本(与宿主 Prompt 同源),
1205
- * 取不到时回退到传入的默认文本。
1206
- */
1207
- function keyShortcut(api: TuiPluginApi, command: string, fallback: string): string {
1208
- try {
1209
- const binds = api.tuiConfig.keybinds.get(command)
1210
- const seq = binds?.map((b) => ({ key: b.key }))
1211
- const s = api.keys.formatBindings(seq as unknown as SequenceBindingLike[])
1212
- return s || fallback
1213
- } catch {
1214
- return fallback
1215
- }
1216
- }
1217
-
1218
- /**
1219
- * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
1220
- * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
1221
- * 三合一信息与路径同行显示在中间位置。
1222
- */
1223
- function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element {
1224
- const KV_PREFIX = "cache_panel"
1225
- const t = createT(() => props.signals.langCode())
1226
-
1227
- const sid = props.sessionId
1228
-
1229
- // ── 命中率(单条口径:最后一条有 token 的 assistant 消息)+ token 汇总 ──
1230
- const stats = createMemo(() => {
1231
- const id = sid
1232
- if (!id) return null
1233
- const msgs = props.api.state.session.messages(id) as Message[]
1234
- const session = typeof props.api.state.session.get === "function"
1235
- ? props.api.state.session.get(id)
1236
- : undefined
1237
- let input = session?.tokens?.input ?? 0
1238
- let read = session?.tokens?.cache?.read ?? 0
1239
- let write = session?.tokens?.cache?.write ?? 0
1240
- // 旧 SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
1241
- if (session?.tokens == null) {
1242
- for (const m of msgs) {
1243
- if (m.role !== "assistant") continue
1244
- const tk = (m as AssistantMessage).tokens
1245
- if (!tk) continue
1246
- input += num(tk.input)
1247
- read += num(tk.cache?.read)
1248
- write += num(tk.cache?.write)
1249
- }
1250
- }
1251
- // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
1252
- // 分母含缓存写(业界口径:read / (input+read+write))
1253
- let hitRate = -1, prevHitRate = -1
1254
- for (let i = msgs.length - 1; i >= 0; i--) {
1255
- const m = msgs[i]
1256
- if (m.role !== "assistant") continue
1257
- const tk = (m as AssistantMessage).tokens
1258
- if (!tk) continue
1259
- const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
1260
- const mrt = num(tk.cache?.read)
1261
- if (mit <= 0) continue
1262
- const rate = (mrt / mit) * 100
1263
- if (hitRate < 0) { hitRate = rate; continue }
1264
- prevHitRate = rate
1265
- break
1266
- }
1267
- return { hitRate, prevHitRate, input, read, write }
1268
- })
1269
-
1270
- // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
1271
-
1272
- // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
1273
- createEffect(() => {
1274
- if (!props.signals.autoBalance()) return
1275
- const id = sid
1276
- if (!id) return
1277
- const msgs = props.api.state.session.messages(id) as Message[]
1278
- let pid = ""
1279
- for (let i = msgs.length - 1; i >= 0; i--) {
1280
- const m = msgs[i]
1281
- if (m.role === "assistant" && (m as AssistantMessage).providerID) { pid = (m as AssistantMessage).providerID; break }
1282
- }
1283
- if (!pid) {
1284
- try { pid = props.api.state.session.get(id)?.model?.providerID ?? "" } catch {}
1285
- }
1286
- if (!pid) return
1287
- const hit = matchBalanceProvider(pid)
1288
- if (hit) {
1289
- props.signals.setBalanceUnsupported(false)
1290
- if (hit.id !== props.signals.balanceProviderId()) {
1291
- props.signals.setBalanceProviderId(hit.id)
1292
- props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
1293
- }
1294
- } else {
1295
- // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
1296
- props.signals.setBalanceUnsupported(true)
1297
- }
1298
- })
1299
-
1300
- // ── 主题色(与侧边栏同口径)──
1301
- const pal = createMemo(() => {
1302
- const th = props.api.theme.current as Record<string, unknown>
1303
- const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
1304
- return {
1305
- text: sat("text", FALLBACK.text),
1306
- muted: sat("textMuted", FALLBACK.muted),
1307
- success: sat("success", FALLBACK.success),
1308
- warning: sat("warning", FALLBACK.warning),
1309
- error: sat("error", FALLBACK.error),
1310
- }
1311
- })
1312
-
1313
- const hitColor = createMemo(() => {
1314
- const r = stats()?.hitRate ?? -1
1315
- if (r >= 85) return pal().success
1316
- if (r >= 70) return pal().warning
1317
- return pal().error
1318
- })
1319
-
1320
- // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
1321
- const trend = createMemo(() => {
1322
- const s = stats()
1323
- if (!s || s.prevHitRate < 0 || s.hitRate < 0) return null
1324
- const d = s.hitRate - s.prevHitRate
1325
- return Math.abs(d) < 0.05 ? null : d
1326
- })
1327
-
1328
- const balanceText = createMemo(() => {
1329
- const s = props.signals.balanceState()
1330
- if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate())
1331
- if (s.status === "loading") return "\u2026"
1332
- if (s.status === "error") return "\u26a0"
1333
- return "-"
1334
- })
1335
-
1336
- // 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
1337
- const directory = createMemo(() => {
1338
- try { return props.api.state.path.directory } catch { return "" }
1339
- })
1340
-
1341
- // 终端宽度信号:初始读取渲染器,窗口 resize 时更新(宿主不约束 hint 行宽度,
1342
- // 路径截断必须基于终端宽度手动计算)。
1343
- // CliRenderer 继承的 EventEmitter 因项目未装 @types/node 类型不可见,
1344
- // 用最小接口声明补齐 resize 事件的 on/off。
1345
- interface ResizeEmitter {
1346
- on(event: "resize", cb: () => void): unknown
1347
- off(event: "resize", cb: () => void): unknown
1348
- }
1349
- const [termW, setTermW] = createSignal(props.api.renderer.terminalWidth)
1350
- // resize 事件(主通道);事件接口若在插件环境不可用则跳过,由轮询兜底
1351
- createEffect(() => {
1352
- const r = props.api.renderer as unknown as ResizeEmitter
1353
- if (typeof r.on !== "function" || typeof r.off !== "function") return
1354
- const onResize = () => setTermW(props.api.renderer.terminalWidth)
1355
- r.on("resize", onResize)
1356
- onCleanup(() => r.off("resize", onResize))
1357
- })
1358
- // 轮询兜底:事件通道若在插件环境不可用,定期同步终端宽度(值不变时不触发更新)
1359
- createEffect(() => {
1360
- const timer = setInterval(() => setTermW(props.api.renderer.terminalWidth), 500)
1361
- onCleanup(() => clearInterval(timer))
1362
- })
1363
-
1364
- // 统计部分分段(单一数据源):量宽拼接 text,渲染逐段着色,避免双源漂移
1365
- const statsSegs = createMemo<{ text: string; color: string | undefined }[]>(() => {
1366
- const s = stats()
1367
- const hr = s && s.hitRate >= 0 ? (Math.floor(s.hitRate * 10) / 10).toFixed(1) + "%" : "--"
1368
- const segs: { text: string; color: string | undefined }[] = [
1369
- { text: t("barHit") + " ", color: pal().muted },
1370
- { text: hr, color: hitColor() },
1371
- ]
1372
- const tr = trend()
1373
- if (tr !== null) {
1374
- segs.push({ text: " " + (tr > 0 ? "\u2191" : "\u2193") + Math.abs(tr).toFixed(1) + "%", color: tr > 0 ? pal().success : pal().error })
1375
- }
1376
- segs.push({ text: " \u00b7 " + t("barTok") + " ", color: pal().muted })
1377
- segs.push({ text: s ? fmtCompact(s.input + s.read + s.write) : "--", color: pal().text })
1378
- if (!props.signals.balanceUnsupported()) {
1379
- segs.push({ text: " \u00b7 " + t("barBal") + " ", color: pal().muted })
1380
- segs.push({ text: balanceText(), color: pal().text })
1381
- }
1382
- segs.push({ text: " \u00b7 ", color: pal().muted })
1383
- return segs
1384
- })
1385
- const statsW = createMemo(() => {
1386
- let w = 0
1387
- for (const sg of statsSegs()) w += visualWidth(sg.text)
1388
- return w
1389
- })
1390
-
1391
- // 宿主右侧 usage 文本复刻(1.18.16 Prompt 口径):
1392
- // 最后一条 output>0 的 assistant 消息 → tokens 合计格式化 + 模型 context limit 百分比 + session 累计费用
1393
- const sessionCost = createMemo(() => {
1394
- try { return num(props.api.state.session.get(sid)?.cost) } catch { return 0 }
1395
- })
1396
- const usageText = createMemo(() => {
1397
- const id = sid
1398
- if (!id) return ""
1399
- const msgs = props.api.state.session.messages(id) as Message[]
1400
- let last: AssistantMessage | undefined
1401
- for (let i = msgs.length - 1; i >= 0; i--) {
1402
- const m = msgs[i]
1403
- if (m.role !== "assistant") continue
1404
- const tk = (m as AssistantMessage).tokens
1405
- if (tk && num(tk.output) > 0) { last = m as AssistantMessage; break }
1406
- }
1407
- if (!last) return ""
1408
- const tk = last.tokens
1409
- if (!tk) return ""
1410
- const tokens = num(tk.input) + num(tk.output) + num(tk.reasoning) + num(tk.cache?.read) + num(tk.cache?.write)
1411
- if (tokens <= 0) return ""
1412
- let pct = ""
1413
- try {
1414
- const p = props.api.state.provider.find((x) => x.id === last.providerID)
1415
- const limit = p?.models?.[last.modelID]?.limit?.context
1416
- if (typeof limit === "number" && limit > 0) pct = ` (${Math.round((tokens / limit) * 100)}%)`
1417
- } catch {}
1418
- const context = fmtCompact(tokens) + pct
1419
- const cost = sessionCost()
1420
- return cost > 0 ? context + " \u00b7 " + fmtCost(cost) : context
1421
- })
1422
-
1423
- // 宿主 1513 行右侧文本:usage(有数据)或 "快捷键 agents"(无数据)+ commands,
1424
- // 快捷键从宿主 keymap 动态读取
1425
- const rightText = createMemo(() => {
1426
- const cmds = keyShortcut(props.api, "command.palette.show", "ctrl+p") + " commands"
1427
- const u = usageText()
1428
- if (u) return u + " " + cmds
1429
- return keyShortcut(props.api, "agent.cycle", "") + " agents " + cmds
1430
- })
1431
- const rightW = createMemo(() => visualWidth(rightText()))
1432
-
1433
- // 输入框实际宽度 = 终端宽度 - 侧边栏(可见时 42) - 边距 4
1434
- // (与宿主 session 布局 contentWidth 口径一致;侧边栏可见性由本面板挂载状态驱动)
1435
- const inputW = createMemo(() => termW() - (props.signals.sidebarVisible() ? 42 : 0) - 4)
1436
-
1437
- // 路径可用宽度 = 输入框宽度 - 统计宽度(精确) - 宿主右侧宽度(动态) - 布局开销;
1438
- // 低于 HIDE_PATH_BELOW 时整体隐藏路径(宽度归零),把空间让给统计与 commands
1439
- const dirDisplay = createMemo(() => {
1440
- const avail = inputW() - statsW() - rightW() - PATH_CHROME
1441
- if (avail < HIDE_PATH_BELOW) return ""
1442
- return truncateVisual(directory(), avail)
1443
- })
1444
- // 状态栏关闭(仅路径)时同样在极窄条件下隐藏路径
1445
- const dirFallback = createMemo(() => {
1446
- const avail = inputW() - rightW() - PATH_CHROME
1447
- if (avail < HIDE_PATH_BELOW) return ""
1448
- return truncateVisual(directory(), avail)
1449
- })
1450
-
1451
- // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
1452
- onMount(() => {
1453
- try {
1454
- const v = props.api.kv.get<boolean>(`${KV_PREFIX}.section.bottom`, true)
1455
- props.signals.setSectionBottom(v !== false)
1456
- } catch {}
1457
- })
1458
-
1459
- return (
1460
- <Show
1461
- when={props.signals.sectionBottom()}
1462
- fallback={<text fg={pal().muted}>{dirFallback()}</text>}
1463
- >
1464
- <box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
1465
- <text fg={pal().muted}>{dirDisplay()}</text>
1466
- <box flexDirection="row">
1467
- <text>
1468
- <For each={statsSegs()}>
1469
- {(sg) => <span style={{ fg: sg.color }}>{sg.text}</span>}
1470
- </For>
1471
- </text>
1472
- </box>
1473
- </box>
1474
- </Show>
1475
- )
1476
- }
1477
-
1478
- function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
1479
- let lastSlotSid = ""
1480
- return {
1481
- order: 55,
1482
- slots: {
1483
- sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
1484
- // ── auto-clear override when the user navigates to a different main session ──
1485
- if (input.session_id !== lastSlotSid) {
1486
- lastSlotSid = input.session_id
1487
- if (signals.overrideSessionId()) {
1488
- signals.setOverrideSessionId(undefined)
1489
- api.kv.set("cache_panel.session", "")
1490
- }
1491
- }
1492
- return (
1493
- <TokenCachePanel
1494
- theme={ctx.theme.current}
1495
- api={api}
1496
- sessionId={input.session_id}
1497
- signals={signals}
1498
- />
1499
- )
1500
- },
1501
- },
1502
- }
1503
- }
1504
-
1505
- const tui: TuiPlugin = async (api: TuiPluginApi) => {
1506
- // ── shared panel signals ──────────────────────────────────────
1507
- const [currencySymbol, setCurrencySymbol] = createSignal("$")
1508
- const [exchangeRate, setExchangeRate] = createSignal(1)
1509
- const [sectionDetail, setSectionDetail] = createSignal(true)
1510
- const [sectionModel, setSectionModel] = createSignal(true)
1511
- const [sectionDist, setSectionDist] = createSignal(true)
1512
- const [sectionSkills, setSectionSkills] = createSignal(true)
1513
- const [sectionBalance, setSectionBalance] = createSignal(true)
1514
- const [sectionBottom, setSectionBottom] = createSignal(true)
1515
- const [balanceRefresh, setBalanceRefresh] = createSignal(0)
1516
- const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
1517
- const [autoBalance, setAutoBalance] = createSignal(true)
1518
- const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
1519
- const [balanceCurrency, setBalanceCurrency] = createSignal("")
1520
- const [borderVisible, setBorderVisible] = createSignal(true)
1521
- const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
1522
- const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
1523
- // 侧边栏可见性(由 TokenCachePanel 挂载状态驱动):可见时宿主输入框宽度 = 终端宽 - 42 - 4
1524
- const [sidebarVisible, setSidebarVisible] = createSignal(false)
1525
-
1526
- // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1527
- // 避免重复请求导致两处余额不一致 ──
1528
- const [balanceState, setBalanceState] = createSignal<BalanceState>({
1529
- status: "idle", data: null, lastFetch: 0,
1530
- })
1531
- // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
1532
- let balanceSeq = 0
1533
-
1534
- const signals: PanelSignals = {
1535
- currencySymbol, setCurrencySymbol,
1536
- exchangeRate, setExchangeRate,
1537
- langCode, setLangCode,
1538
- sectionDetail, setSectionDetail,
1539
- sectionModel, setSectionModel,
1540
- sectionDist, setSectionDist,
1541
- sectionSkills, setSectionSkills,
1542
- sectionBalance, setSectionBalance,
1543
- sectionBottom, setSectionBottom,
1544
- balanceRefresh, setBalanceRefresh,
1545
- balanceProviderId, setBalanceProviderId,
1546
- autoBalance, setAutoBalance,
1547
- balanceUnsupported, setBalanceUnsupported,
1548
- balanceState,
1549
- balanceCurrency, setBalanceCurrency,
1550
- borderVisible, setBorderVisible,
1551
- overrideSessionId, setOverrideSessionId,
1552
- sidebarVisible, setSidebarVisible,
1553
- }
1554
-
1555
- api.slots.register(createSidebarSlot(api, signals))
1556
-
1557
- // 输入框 hint 行(session_prompt slot,replace 模式):
1558
- // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
1559
- // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
1560
- api.slots.register({
1561
- order: 55,
1562
- slots: {
1563
- session_prompt(
1564
- _ctx: TuiSlotContext,
1565
- input: {
1566
- session_id: string
1567
- visible?: boolean
1568
- disabled?: boolean
1569
- on_submit?: () => void
1570
- ref?: (ref: TuiPromptRef | undefined) => void
1571
- },
1572
- ): JSX.Element {
1573
- return (
1574
- <api.ui.Prompt
1575
- sessionID={input.session_id}
1576
- visible={input.visible}
1577
- disabled={input.disabled}
1578
- onSubmit={input.on_submit}
1579
- ref={input.ref}
1580
- hint={<BottomStatusBar api={api} signals={signals} sessionId={input.session_id} />}
1581
- />
1582
- )
1583
- },
1584
- },
1585
- })
1586
-
1587
- // ── slash commands for runtime config ──
1588
- const KV_PREFIX = "cache_panel"
1589
-
1590
- // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
1591
- const restoreLang = () => {
1592
- try {
1593
- const saved = api.kv.get<string>(`${KV_PREFIX}.lang`)
1594
- if (saved && LANG_META.some((m) => m.code === saved)) setLangCode(saved as LangCode)
1595
- } catch {}
1596
- }
1597
- if (api.kv.ready) {
1598
- restoreLang()
1599
- } else {
1600
- const langTimer = setInterval(() => {
1601
- if (api.kv.ready) { clearInterval(langTimer); restoreLang() }
1602
- }, 10)
1603
- api.lifecycle.onDispose(() => clearInterval(langTimer))
1604
- }
1605
-
1606
- const pollBalance = async () => {
1607
- const provider = getBalanceProvider(balanceProviderId())
1608
- // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
1609
- const key = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1610
- || findOpencodeKey(api, provider)
1611
- if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
1612
- if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
1613
- const now = Date.now()
1614
- const prev = balanceState()
1615
- // key 已更换(重新输入)→ 强制重新查询,绕过缓存
1616
- if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
1617
- const seq = ++balanceSeq
1618
- setBalanceState({ ...prev, status: "loading", error: undefined, key })
1619
- const controller = new AbortController()
1620
- let timedOut = false
1621
- const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
1622
- try {
1623
- const data = await provider.fetchBalance(key, controller.signal)
1624
- clearTimeout(timer)
1625
- if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
1626
- setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
1627
- } catch (err) {
1628
- clearTimeout(timer)
1629
- if (seq !== balanceSeq) return
1630
- const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
1631
- // 失败时清空旧数据,避免显示过期余额
1632
- setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
1633
- }
1634
- }
1635
-
1636
- // Re-fetch when the API key is (re)configured via /cache-balance-key.
1637
- // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
1638
- // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
1639
- // 形成无限循环(每次重跑都发起新的 fetch 请求)。
1640
- createEffect(() => {
1641
- void balanceRefresh()
1642
- untrack(() => { void pollBalance() })
1643
- })
1644
-
1645
- // 定时轮询(5 分钟);随插件生命周期清理
1646
- const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
1647
- api.lifecycle.onDispose(() => clearInterval(balanceTimer))
1648
-
1649
- /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
1650
- const providerOptionTitle = (p: BalanceProvider, current?: string) => {
1651
- const t = createT(() => langCode())
1652
- const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
1653
- const hasAuto = !hasManual && !!findOpencodeKey(api, p)
1654
- const mark = hasManual
1655
- ? t("keyUser")
1656
- : hasAuto
1657
- ? t("keyOpenCode")
1658
- : t("keyNotSet")
1659
- return p.name + mark + (current && p.id === current ? " *" : "")
1660
- }
1661
-
1662
- /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
1663
- const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
1664
- const t = createT(() => langCode())
1665
- const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1666
- const masked = maskKey(current)
1667
- dialog?.replace(() => (
1668
- <api.ui.DialogPrompt
1669
- title={provider.name}
1670
- description={() => <text>{t("balKeyPrompt", { p: provider.name })}</text>}
1671
- placeholder={provider.keyPlaceholder ?? "sk-..."}
1672
- value={masked}
1673
- onConfirm={(val) => {
1674
- const input = val.trim()
1675
- let key: string
1676
- if (input === "") {
1677
- key = ""
1678
- } else if (input.includes("*")) {
1679
- key = current
1680
- } else {
1681
- key = input
1682
- }
1683
- api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
1684
- setBalanceRefresh(v => v + 1)
1685
- if (key) {
1686
- api.ui.toast({ message: t("keySaved") })
1687
- } else {
1688
- api.ui.toast({ message: t("keyCleared") })
1689
- }
1690
- dialog?.clear()
1691
- }}
1692
- onCancel={() => dialog?.clear()}
1693
- />
1694
- ))
1695
- }
1696
-
1697
- api.command?.register(() => [
1698
- {
1699
- title: "Cache: Set Currency",
1700
- value: "cache.currency",
1701
- description: "Change the currency unit for cost display",
1702
- slash: { name: "cache-currency" },
1703
- onSelect: (dialog) => {
1704
- dialog?.replace(() => (
1705
- <api.ui.DialogSelect
1706
- title="Select Currency"
1707
- options={Object.entries(CURRENCIES).map(([code, sym]) => ({
1708
- title: `${code} (${sym})`,
1709
- value: code,
1710
- }))}
1711
- onSelect={(opt) => {
1712
- const t = createT(() => langCode())
1713
- const sym = CURRENCIES[opt.value] ?? "$"
1714
- const defRate = DEFAULT_RATES[opt.value] ?? 1
1715
- api.kv.set(`${KV_PREFIX}.currency`, sym)
1716
- api.kv.set(`${KV_PREFIX}.rate`, defRate)
1717
- // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算
1718
- api.kv.set(`${KV_PREFIX}.balance_currency`, opt.value)
1719
- signals.setBalanceCurrency(opt.value)
1720
- signals.setCurrencySymbol(sym)
1721
- signals.setExchangeRate(defRate)
1722
- api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) })
1723
- dialog?.clear()
1724
- }}
1725
- />
1726
- ))
1727
- },
1728
- },
1729
- {
1730
- title: "Cache: Set Exchange Rate",
1731
- value: "cache.rate",
1732
- description: "Set the exchange rate multiplier for the selected currency",
1733
- slash: { name: "cache-rate" },
1734
- onSelect: (dialog) => {
1735
- dialog?.replace(() => (
1736
- <api.ui.DialogPrompt
1737
- title="Exchange Rate"
1738
- description={() => <text>Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)</text>}
1739
- placeholder="1.0"
1740
- value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
1741
- onConfirm={(val) => {
1742
- const t = createT(() => langCode())
1743
- const n = parseFloat(val)
1744
- if (n > 0) {
1745
- api.kv.set(`${KV_PREFIX}.rate`, n)
1746
- signals.setExchangeRate(n)
1747
- api.ui.toast({ message: t("rateSet", { r: n }) })
1748
- }
1749
- dialog?.clear()
1750
- }}
1751
- />
1752
- ))
1753
- },
1754
- },
1755
- {
1756
- title: "Cache: Toggle Section",
1757
- value: "cache.section",
1758
- description: "Show or hide a sidebar section",
1759
- slash: { name: "cache-section" },
1760
- onSelect: (dialog) => {
1761
- const t = createT(() => langCode())
1762
- const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1763
- const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1764
- const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1765
- const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1766
- const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1767
- const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
1768
- const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1769
- const labels: Record<string, string> = {
1770
- detail: t("secDetail"),
1771
- model: t("secModel"),
1772
- dist: t("distTitle"),
1773
- skills: t("secSkills"),
1774
- balance: t("secBalance"),
1775
- bottom: t("secBottom"),
1776
- border: t("secBorder"),
1777
- }
1778
- const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`
1779
- dialog?.replace(() => (
1780
- <api.ui.DialogSelect
1781
- title={t("secToggle")}
1782
- options={[
1783
- { title: optTitle(labels.detail, detailOn), value: "detail" },
1784
- { title: optTitle(labels.model, modelOn), value: "model" },
1785
- { title: optTitle(labels.dist, distOn), value: "dist" },
1786
- { title: optTitle(labels.skills, skillsOn), value: "skills" },
1787
- { title: optTitle(labels.balance, balanceOn), value: "balance" },
1788
- { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
1789
- { title: optTitle(labels.border, borderOn), value: "border" },
1790
- ]}
1791
- onSelect={(opt) => {
1792
- if (opt.value === "border") {
1793
- const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1794
- api.kv.set(`${KV_PREFIX}.border`, !cur)
1795
- signals.setBorderVisible(!cur)
1796
- api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
1797
- } else {
1798
- const key = `${KV_PREFIX}.section.${opt.value}`
1799
- const cur = Boolean(api.kv.get(key, true))
1800
- api.kv.set(key, !cur)
1801
- if (opt.value === "detail") signals.setSectionDetail(!cur)
1802
- if (opt.value === "model") signals.setSectionModel(!cur)
1803
- if (opt.value === "dist") signals.setSectionDist(!cur)
1804
- if (opt.value === "skills") signals.setSectionSkills(!cur)
1805
- if (opt.value === "balance") signals.setSectionBalance(!cur)
1806
- if (opt.value === "bottom") signals.setSectionBottom(!cur)
1807
- const name = labels[opt.value] ?? opt.value
1808
- api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) })
1809
- }
1810
- dialog?.clear()
1811
- }}
1812
- />
1813
- ))
1814
- },
1815
- },
1816
- {
1817
- title: "Cache: Show Config",
1818
- value: "cache.config",
1819
- description: "Display the current plugin configuration",
1820
- slash: { name: "cache-config" },
1821
- onSelect: (dialog) => {
1822
- const t = createT(() => langCode())
1823
- const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
1824
- const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
1825
- const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1826
- const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1827
- const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1828
- const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1829
- const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
1830
- const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
1831
- const on = (v: boolean) => v ? "ON" : "OFF"
1832
- api.ui.toast({
1833
- title: t("panelConfigTitle"),
1834
- message: t("panelConfigMsg", {
1835
- c: sym, r: rate,
1836
- d: on(detail), m: on(model),
1837
- t: on(dist), k: on(skills),
1838
- b: on(balance), f: on(bottom),
1839
- }),
1840
- duration: 8000,
1841
- })
1842
- dialog?.clear()
1843
- },
1844
- },
1845
- {
1846
- title: "Cache: Switch Language",
1847
- value: "cache.lang",
1848
- description: "Switch between Chinese and English display",
1849
- slash: { name: "cache-lang" },
1850
- onSelect: (dialog) => {
1851
- const t = createT(() => langCode())
1852
- const cur = langCode()
1853
- dialog?.replace(() => (
1854
- <api.ui.DialogSelect
1855
- title={t("langTitle")}
1856
- options={LANG_META.map((m) => ({
1857
- title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
1858
- value: m.code,
1859
- }))}
1860
- onSelect={(opt) => {
1861
- const code = opt.value as LangCode
1862
- api.kv.set(`${KV_PREFIX}.lang`, code)
1863
- setLangCode(code)
1864
- api.ui.toast({ message: t("langSwitched") })
1865
- dialog?.clear()
1866
- }}
1867
- />
1868
- ))
1869
- },
1870
- },
1871
- {
1872
- title: "Cache: Switch Balance Provider",
1873
- value: "cache.balance",
1874
- description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
1875
- slash: { name: "cache-balance" },
1876
- onSelect: (dialog) => {
1877
- const t = createT(() => langCode())
1878
- const current = signals.balanceProviderId()
1879
- const auto = signals.autoBalance()
1880
- const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
1881
- dialog?.replace(() => (
1882
- <api.ui.DialogSelect
1883
- title={t("balProvTitle")}
1884
- options={[
1885
- {
1886
- title: autoLabel,
1887
- value: "__auto__",
1888
- },
1889
- ...balanceProviders.map((p) => ({
1890
- title: providerOptionTitle(p, current),
1891
- value: p.id,
1892
- })),
1893
- ]}
1894
- onSelect={(opt) => {
1895
- if (opt.value === "__auto__") {
1896
- const next = !auto
1897
- api.kv.set(`${KV_PREFIX}.balance.auto`, next)
1898
- signals.setAutoBalance(next)
1899
- api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") })
1900
- dialog?.clear()
1901
- } else {
1902
- const provider = getBalanceProvider(opt.value)
1903
- // 手动切换会关闭自动切换
1904
- api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
1905
- api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1906
- signals.setBalanceProviderId(provider.id)
1907
- signals.setAutoBalance(false)
1908
- signals.setBalanceUnsupported(false)
1909
- // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
1910
- signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1911
- const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
1912
- if (!hasKey) {
1913
- // 未配置 key → 进入设置流程(对话框保持打开等待输入)
1914
- promptBalanceKey(dialog, provider)
1915
- } else {
1916
- api.ui.toast({ message: t("providerManual", { p: provider.name }) })
1917
- dialog?.clear()
1918
- }
1919
- }
1920
- }}
1921
- />
1922
- ))
1923
- },
1924
- },
1925
- {
1926
- title: "Cache: Set Balance API Key",
1927
- value: "cache.balance.key",
1928
- description: "Select a provider and set its API key for balance display",
1929
- slash: { name: "cache-balance-key" },
1930
- onSelect: (dialog) => {
1931
- const t = createT(() => langCode())
1932
- // 步骤 1:选择 provider
1933
- dialog?.replace(() => (
1934
- <api.ui.DialogSelect
1935
- title={t("balSelectTitle")}
1936
- options={balanceProviders.map((p) => ({
1937
- title: providerOptionTitle(p),
1938
- value: p.id,
1939
- }))}
1940
- onSelect={(opt) => {
1941
- const provider = getBalanceProvider(opt.value)
1942
- // 手动指定 provider 会关闭自动切换
1943
- api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
1944
- api.kv.set(`${KV_PREFIX}.balance.auto`, false)
1945
- signals.setBalanceProviderId(provider.id)
1946
- signals.setAutoBalance(false)
1947
- // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
1948
- signals.setBalanceRefresh(signals.balanceRefresh() + 1)
1949
- // 步骤 2:输入 key
1950
- promptBalanceKey(dialog, provider)
1951
- }}
1952
- />
1953
- ))
1954
- },
1955
- },
1956
- {
1957
- title: "Cache: Debug Skills Detection",
1958
- value: "cache.debug-skills",
1959
- description: "Dump all tool parts found in the current session for skill detection debugging",
1960
- slash: { name: "cache-debug-skills" },
1961
- onSelect: () => {
1962
- const t = createT(() => langCode())
1963
- const rt = api.route.current
1964
- if (rt.name !== "session" || !rt.params) {
1965
- api.ui.toast({ message: t("runInSession"), variant: "warning" })
1966
- return
1967
- }
1968
- const sid = String(rt.params.sessionID)
1969
- const msgs = api.state.session.messages(sid)
1970
- const byTool: Record<string, number> = {}
1971
- const skillParts: string[] = []
1972
- for (const msg of msgs) {
1973
- if (msg.role !== "assistant") continue
1974
- let parts: readonly any[] = []
1975
- try { parts = api.state.part(msg.id) } catch {}
1976
- for (const p of parts) {
1977
- if (p.type === "tool") {
1978
- const t = String(p.tool ?? "?")
1979
- byTool[t] = (byTool[t] ?? 0) + 1
1980
- if (t === "skill") {
1981
- const meta = p.state?.metadata
1982
- const rootMeta = p.metadata
1983
- 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)}"`)
1984
- }
1985
- }
1986
- }
1987
- }
1988
- const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
1989
- const extra = skillParts.length > 0 ? "\n\nSkill parts:\n" + skillParts.join("\n") : "\n\n⚠ No skill tool parts found — AI may be reading SKILL.md instead. Try: 'Use the skill tool to load karpathy-guidelines'"
1990
- api.ui.toast({
1991
- title: `Tool Summary (${Object.keys(byTool).length} types)`,
1992
- message: summary + extra,
1993
- duration: 15000,
1994
- })
1995
- },
1996
- },
1997
- {
1998
- title: "Cache: Sub-Agent Stats",
1999
- value: "cache.session",
2000
- description: "View token cache statistics for a sub-agent by session ID",
2001
- slash: { name: "cache-session" },
2002
- onSelect: (dialog) => {
2003
- // ── 扫描当前主 session 的子代理 session ID 列表 ──
2004
- const rt = api.route.current
2005
- const parentSid = rt.name === "session" && rt.params ? String(rt.params.sessionID) : ""
2006
- const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"])
2007
-
2008
- interface ChildEntry { title: string; value: string; description: string }
2009
- const children: ChildEntry[] = []
2010
- if (parentSid) {
2011
- try {
2012
- const msgs = api.state.session.messages(parentSid)
2013
- for (const msg of msgs) {
2014
- if (msg.role !== "assistant") continue
2015
- let parts: readonly Part[] = []
2016
- try { parts = api.state.part(msg.id) } catch {}
2017
- for (const p of parts) {
2018
- if (p.type !== "tool") continue
2019
- const tool = String((p as ToolPart).tool ?? "")
2020
- if (!SUBAGENT_TOOLS.has(tool)) continue
2021
- const st = (p as any).state as Record<string, unknown> | undefined
2022
- const stMeta = st?.metadata as Record<string, unknown> | undefined
2023
- const subSid = stMeta?.session_id ?? stMeta?.sessionId
2024
- if (!subSid) continue
2025
- const sidStr = String(subSid)
2026
- const input = st?.input as Record<string, unknown> | undefined
2027
- const agent = String((p as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool)
2028
- const prompt = String(input?.prompt ?? "")
2029
- const desc = input?.description ? String(input.description) : ""
2030
- const title = desc || prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 40) || agent
2031
- children.push({ title, value: sidStr, description: `${agent} · ${sidStr.slice(0, 24)}…` })
2032
- }
2033
- }
2034
- } catch {}
2035
- }
2036
-
2037
- // 去重
2038
- const seen = new Set<string>()
2039
- const unique = children.filter(c => { if (seen.has(c.value)) return false; seen.add(c.value); return true })
2040
-
2041
- if (unique.length > 0) {
2042
- // ── 有子代理 → DialogSelect 列表选择 ──
2043
- const t = createT(() => langCode())
2044
- const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
2045
- const options = unique.map((c, i) => ({
2046
- title: `${i + 1}. ${c.title}`,
2047
- value: c.value,
2048
- description: c.description,
2049
- }))
2050
- // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
2051
- const backValue = "__main__"
2052
- const backTitle = `\u2500 ${t("backToMainTitle")}`
2053
- options.unshift({ title: backTitle, value: backValue, description: "" })
2054
- options.push({ title: backTitle, value: backValue, description: "" })
2055
- const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
2056
- dialog?.replace(() => (
2057
- <api.ui.DialogSelect
2058
- title={t("subSelectTitle")}
2059
- options={options}
2060
- current={currentIdx >= 0 ? options[currentIdx].value : undefined}
2061
- onSelect={(opt) => {
2062
- if (opt.value === backValue) {
2063
- signals.setOverrideSessionId(undefined)
2064
- api.kv.set(`${KV_PREFIX}.session`, "")
2065
- api.ui.toast({ message: t("backToMain") })
2066
- } else {
2067
- signals.setOverrideSessionId(opt.value)
2068
- api.kv.set(`${KV_PREFIX}.session`, opt.value)
2069
- api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) })
2070
- }
2071
- dialog?.clear()
2072
- }}
2073
- />
2074
- ))
2075
- } else {
2076
- // ── 无子代理 → DialogPrompt 手动粘贴 ──
2077
- const t = createT(() => langCode())
2078
- dialog?.replace(() => (
2079
- <api.ui.DialogPrompt
2080
- title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")}
2081
- description={() => <text>{t("subNoFound")}</text>}
2082
- placeholder="ses_..."
2083
- value={signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "") ?? ""}
2084
- onConfirm={(val) => {
2085
- const sid = val.trim()
2086
- if (sid) {
2087
- signals.setOverrideSessionId(sid)
2088
- api.kv.set(`${KV_PREFIX}.session`, sid)
2089
- api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
2090
- }
2091
- dialog?.clear()
2092
- }}
2093
- onCancel={() => dialog?.clear()}
2094
- />
2095
- ))
2096
- }
2097
- },
2098
- },
2099
- {
2100
- title: "Cache: Back to Main",
2101
- value: "cache.session.back",
2102
- description: "Return to main session stats",
2103
- slash: { name: "cache-session-back" },
2104
- onSelect: (dialog) => {
2105
- const t = createT(() => langCode())
2106
- signals.setOverrideSessionId(undefined)
2107
- api.kv.set(`${KV_PREFIX}.session`, "")
2108
- api.ui.toast({ message: t("backToMain") })
2109
- dialog?.clear()
2110
- },
2111
- },
2112
- ])
2113
- }
2114
-
2115
- const mod: TuiPluginModule & { id: string } = {
2116
- id: "opencode-visual-cache",
2117
- tui,
2118
- }
2119
-
2120
- export default mod
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import type { JSX } from "@opentui/solid"
4
+ import type {
5
+ TuiPlugin,
6
+ TuiPluginApi,
7
+ TuiSlotContext,
8
+ TuiSlotPlugin,
9
+ TuiPluginModule,
10
+ TuiThemeCurrent,
11
+ TuiDialogStack,
12
+ TuiPromptRef,
13
+ SequenceBindingLike,
14
+ } from "@opencode-ai/plugin/tui"
15
+ import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
16
+ import type {
17
+ Part,
18
+ TextPart,
19
+ ToolPart,
20
+ FilePart,
21
+ ReasoningPart,
22
+ } from "@opencode-ai/sdk/v2"
23
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
24
+ import { PLUGIN_VERSION } from "./_version"
25
+ import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
26
+ import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
27
+ import {
28
+ MAX_SAT, FALLBACK, CURRENCIES, DEFAULT_RATES,
29
+ charColumns, visualWidth, visualPadEnd, truncateVisual, progressBar,
30
+ fmt, num, fmtCost, fmtCompact, formatBalanceAmount, formatBalanceText,
31
+ balanceSymbol, convertBalance, estimateTokens,
32
+ rgb, saturation, desaturateTo, dimColor,
33
+ type TokenDist,
34
+ } from "./core"
35
+ import { TokenCachePanel } from "./panel/TokenCachePanel"
36
+ import type { PanelSignals, BalanceState } from "./panel/panel-api"
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ // Bun / Node globals available at runtime in the OpenCode TUI process
43
+ declare const process: { env: Record<string, string | undefined> } | undefined
44
+ // ── language ──────────────────────────────────────────────────────
45
+ // 语言初始化:环境变量 CACHE_TUI_LANG 覆盖 否则按系统 locale 自动检测。
46
+ // 用户通过 /cache-lang 设置的偏好会在 KV 就绪后优先覆盖(见 tui() 内恢复逻辑)。
47
+
48
+ const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
49
+ const INIT_LANG: LangCode = DEBUG_LANG !== undefined && LANG_META.some((m) => m.code === DEBUG_LANG)
50
+ ? (DEBUG_LANG as LangCode)
51
+ : detectLang()
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Balance state
55
+ // ---------------------------------------------------------------------------
56
+
57
+
58
+ const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes
59
+
60
+ /**
61
+ * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。
62
+ * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn moonshot)。
63
+ * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。
64
+ * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。
65
+ */
66
+ function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string {
67
+ try {
68
+ const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }>
69
+ // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn moonshot)
70
+ const id = provider.id.toLowerCase()
71
+ const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id))
72
+ if (!hit) return ""
73
+ const k = typeof hit.key === "string" ? hit.key : ""
74
+ if (k) return k
75
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : ""
76
+ } catch {
77
+ return ""
78
+ }
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Sidebar component
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** Signals shared between the TUI component and slash commands.
86
+ * Created in the `tui` function scope so they do not survive module reload —
87
+ * the component re-creates them on mount and restores user config from kv. */
88
+
89
+ const MIN_PANEL_WIDTH = 20
90
+ const DEFAULT_PANEL_WIDTH = 26
91
+
92
+ /** ── layout measurement constants (visual columns) ── */
93
+ const LABEL_GAP = 1 // label(如 "Hit")后面的空格
94
+ const BAR_BRACKETS = 2 // "[" + "]" 包围进度条
95
+ const BAR_GAP = 1 // "]" 后面的空格
96
+ const PCT_FIXED_WIDTH = 5 // "XX.X%" 固定 5 字符宽度
97
+ const HEADER_PREFIX = 2 // 折叠态标题行:▶/▼ 图标 + 后面的空格
98
+ const UNIT_GAP = 1 // 计量单位前的空格(如 "tok")
99
+
100
+
101
+
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Plugin entry
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * 路径截断的固定开销(列数):仅宿主布局常量,不含任何内容宽度——
109
+ * 统计宽度、宿主 usage、commands 快捷键均为运行时动态计算。
110
+ * - marginLeft=1 + space-between 余量 ≈ 2 列
111
+ */
112
+ const PATH_CHROME = 2
113
+ /**
114
+ * 路径可用宽度低于此列数时整体隐藏(极窄终端下路径信息价值太低,
115
+ * 残留的 "E:\Work…" 反而挤压右侧统计与 commands,直接让位更干净)。
116
+ */
117
+ const HIDE_PATH_BELOW = 14
118
+
119
+ /**
120
+ * 从宿主 keymap 动态读取命令的快捷键显示文本(与宿主 Prompt 同源),
121
+ * 取不到时回退到传入的默认文本。
122
+ */
123
+ function keyShortcut(api: TuiPluginApi, command: string, fallback: string): string {
124
+ try {
125
+ const binds = api.tuiConfig.keybinds.get(command)
126
+ const seq = binds?.map((b) => ({ key: b.key }))
127
+ const s = api.keys.formatBindings(seq as unknown as SequenceBindingLike[])
128
+ return s || fallback
129
+ } catch {
130
+ return fallback
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 输入框 hint 行(session_prompt slot hint):单行显示 路径 · 命中率 · 余额 · Tokens。
136
+ * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
137
+ * 三合一信息与路径同行显示在中间位置。
138
+ */
139
+ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sessionId: string }): JSX.Element {
140
+ const KV_PREFIX = "cache_panel"
141
+ const t = createT(() => props.signals.langCode())
142
+
143
+ const sid = props.sessionId
144
+
145
+ // ── 命中率(单条口径:最后一条有 token assistant 消息)+ token 汇总 ──
146
+ const stats = createMemo(() => {
147
+ const id = sid
148
+ if (!id) return null
149
+ const msgs = props.api.state.session.messages(id) as Message[]
150
+ const session = typeof props.api.state.session.get === "function"
151
+ ? props.api.state.session.get(id)
152
+ : undefined
153
+ let input = session?.tokens?.input ?? 0
154
+ let read = session?.tokens?.cache?.read ?? 0
155
+ let write = session?.tokens?.cache?.write ?? 0
156
+ // SDK 无 session 聚合字段 → 遍历消息累加(与侧边栏 fallback 一致)
157
+ if (session?.tokens == null) {
158
+ for (const m of msgs) {
159
+ if (m.role !== "assistant") continue
160
+ const tk = (m as AssistantMessage).tokens
161
+ if (!tk) continue
162
+ input += num(tk.input)
163
+ read += num(tk.cache?.read)
164
+ write += num(tk.cache?.write)
165
+ }
166
+ }
167
+ // 从后往前取最后两条有 token 数据的 assistant 消息 → 单条命中率 + 趋势
168
+ // 分母含缓存写(业界口径:read / (input+read+write)
169
+ let hitRate = -1, prevHitRate = -1
170
+ for (let i = msgs.length - 1; i >= 0; i--) {
171
+ const m = msgs[i]
172
+ if (m.role !== "assistant") continue
173
+ const tk = (m as AssistantMessage).tokens
174
+ if (!tk) continue
175
+ const mit = num(tk.input) + num(tk.cache?.read) + num(tk.cache?.write)
176
+ const mrt = num(tk.cache?.read)
177
+ if (mit <= 0) continue
178
+ const rate = (mrt / mit) * 100
179
+ if (hitRate < 0) { hitRate = rate; continue }
180
+ prevHitRate = rate
181
+ break
182
+ }
183
+ return { hitRate, prevHitRate, input, read, write }
184
+ })
185
+
186
+ // 余额查询状态为共享信号(PanelSignals.balanceState),由 tui() 统一轮询
187
+
188
+ // 自动切换 provider(跟随当前会话模型;幂等,与侧边栏共享信号)
189
+ createEffect(() => {
190
+ if (!props.signals.autoBalance()) return
191
+ const id = sid
192
+ if (!id) return
193
+ const msgs = props.api.state.session.messages(id) as Message[]
194
+ let pid = ""
195
+ for (let i = msgs.length - 1; i >= 0; i--) {
196
+ const m = msgs[i]
197
+ if (m.role === "assistant" && (m as AssistantMessage).providerID) { pid = (m as AssistantMessage).providerID; break }
198
+ }
199
+ if (!pid) {
200
+ try { pid = props.api.state.session.get(id)?.model?.providerID ?? "" } catch {}
201
+ }
202
+ if (!pid) return
203
+ const hit = matchBalanceProvider(pid)
204
+ if (hit) {
205
+ props.signals.setBalanceUnsupported(false)
206
+ if (hit.id !== props.signals.balanceProviderId()) {
207
+ props.signals.setBalanceProviderId(hit.id)
208
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1)
209
+ }
210
+ } else {
211
+ // 当前提供商没有余额适配器 → 标记不支持,余额显示 N/A 并停止轮询
212
+ props.signals.setBalanceUnsupported(true)
213
+ }
214
+ })
215
+
216
+ // ── 主题色(与侧边栏同口径)──
217
+ const pal = createMemo(() => {
218
+ const th = props.api.theme.current as Record<string, unknown>
219
+ const sat = (k: string, fb: string) => desaturateTo(th[k], MAX_SAT, fb)
220
+ return {
221
+ text: sat("text", FALLBACK.text),
222
+ muted: sat("textMuted", FALLBACK.muted),
223
+ success: sat("success", FALLBACK.success),
224
+ warning: sat("warning", FALLBACK.warning),
225
+ error: sat("error", FALLBACK.error),
226
+ }
227
+ })
228
+
229
+ const hitColor = createMemo(() => {
230
+ const r = stats()?.hitRate ?? -1
231
+ if (r >= 85) return pal().success
232
+ if (r >= 70) return pal().warning
233
+ return pal().error
234
+ })
235
+
236
+ // 命中率趋势:最后一条与上一条的差值;|Δ| < 0.05 视为无变化(null = 不显示)
237
+ const trend = createMemo(() => {
238
+ const s = stats()
239
+ if (!s || s.prevHitRate < 0 || s.hitRate < 0) return null
240
+ const d = s.hitRate - s.prevHitRate
241
+ return Math.abs(d) < 0.05 ? null : d
242
+ })
243
+
244
+ const balanceText = createMemo(() => {
245
+ const s = props.signals.balanceState()
246
+ if (s.status === "ok" && s.data) return formatBalanceText(s.data, props.signals.balanceCurrency(), props.signals.exchangeRate())
247
+ if (s.status === "loading") return "\u2026"
248
+ if (s.status === "error") return "\u26a0"
249
+ return "-"
250
+ })
251
+
252
+ // 路径显示(替换宿主默认 hint 左侧的 cwd 文本)
253
+ const directory = createMemo(() => {
254
+ try { return props.api.state.path.directory } catch { return "" }
255
+ })
256
+
257
+ // 终端宽度信号:初始读取渲染器,窗口 resize 时更新(宿主不约束 hint 行宽度,
258
+ // 路径截断必须基于终端宽度手动计算)。
259
+ // CliRenderer 继承的 EventEmitter 因项目未装 @types/node 类型不可见,
260
+ // 用最小接口声明补齐 resize 事件的 on/off。
261
+ interface ResizeEmitter {
262
+ on(event: "resize", cb: () => void): unknown
263
+ off(event: "resize", cb: () => void): unknown
264
+ }
265
+ const [termW, setTermW] = createSignal(props.api.renderer.terminalWidth)
266
+ // resize 事件(主通道);事件接口若在插件环境不可用则跳过,由轮询兜底
267
+ createEffect(() => {
268
+ const r = props.api.renderer as unknown as ResizeEmitter
269
+ if (typeof r.on !== "function" || typeof r.off !== "function") return
270
+ const onResize = () => setTermW(props.api.renderer.terminalWidth)
271
+ r.on("resize", onResize)
272
+ onCleanup(() => r.off("resize", onResize))
273
+ })
274
+ // 轮询兜底:事件通道若在插件环境不可用,定期同步终端宽度(值不变时不触发更新)
275
+ createEffect(() => {
276
+ const timer = setInterval(() => setTermW(props.api.renderer.terminalWidth), 500)
277
+ onCleanup(() => clearInterval(timer))
278
+ })
279
+
280
+ // 统计部分分段(单一数据源):量宽拼接 text,渲染逐段着色,避免双源漂移
281
+ const statsSegs = createMemo<{ text: string; color: string | undefined }[]>(() => {
282
+ const s = stats()
283
+ const hr = s && s.hitRate >= 0 ? (Math.floor(s.hitRate * 10) / 10).toFixed(1) + "%" : "--"
284
+ const segs: { text: string; color: string | undefined }[] = [
285
+ { text: t("barHit") + " ", color: pal().muted },
286
+ { text: hr, color: hitColor() },
287
+ ]
288
+ const tr = trend()
289
+ if (tr !== null) {
290
+ segs.push({ text: " " + (tr > 0 ? "\u2191" : "\u2193") + Math.abs(tr).toFixed(1) + "%", color: tr > 0 ? pal().success : pal().error })
291
+ }
292
+ segs.push({ text: " \u00b7 " + t("barTok") + " ", color: pal().muted })
293
+ segs.push({ text: s ? fmtCompact(s.input + s.read + s.write) : "--", color: pal().text })
294
+ if (!props.signals.balanceUnsupported()) {
295
+ segs.push({ text: " \u00b7 " + t("barBal") + " ", color: pal().muted })
296
+ segs.push({ text: balanceText(), color: pal().text })
297
+ }
298
+ segs.push({ text: " \u00b7 ", color: pal().muted })
299
+ return segs
300
+ })
301
+ const statsW = createMemo(() => {
302
+ let w = 0
303
+ for (const sg of statsSegs()) w += visualWidth(sg.text)
304
+ return w
305
+ })
306
+
307
+ // 宿主右侧 usage 文本复刻(1.18.16 Prompt 口径):
308
+ // 最后一条 output>0 assistant 消息 tokens 合计格式化 + 模型 context limit 百分比 + session 累计费用
309
+ const sessionCost = createMemo(() => {
310
+ try { return num(props.api.state.session.get(sid)?.cost) } catch { return 0 }
311
+ })
312
+ const usageText = createMemo(() => {
313
+ const id = sid
314
+ if (!id) return ""
315
+ const msgs = props.api.state.session.messages(id) as Message[]
316
+ let last: AssistantMessage | undefined
317
+ for (let i = msgs.length - 1; i >= 0; i--) {
318
+ const m = msgs[i]
319
+ if (m.role !== "assistant") continue
320
+ const tk = (m as AssistantMessage).tokens
321
+ if (tk && num(tk.output) > 0) { last = m as AssistantMessage; break }
322
+ }
323
+ if (!last) return ""
324
+ const tk = last.tokens
325
+ if (!tk) return ""
326
+ const tokens = num(tk.input) + num(tk.output) + num(tk.reasoning) + num(tk.cache?.read) + num(tk.cache?.write)
327
+ if (tokens <= 0) return ""
328
+ let pct = ""
329
+ try {
330
+ const p = props.api.state.provider.find((x) => x.id === last.providerID)
331
+ const limit = p?.models?.[last.modelID]?.limit?.context
332
+ if (typeof limit === "number" && limit > 0) pct = ` (${Math.round((tokens / limit) * 100)}%)`
333
+ } catch {}
334
+ const context = fmtCompact(tokens) + pct
335
+ const cost = sessionCost()
336
+ return cost > 0 ? context + " \u00b7 " + fmtCost(cost) : context
337
+ })
338
+
339
+ // 宿主 1513 行右侧文本:usage(有数据)或 "快捷键 agents"(无数据)+ commands,
340
+ // 快捷键从宿主 keymap 动态读取
341
+ const rightText = createMemo(() => {
342
+ const cmds = keyShortcut(props.api, "command.palette.show", "ctrl+p") + " commands"
343
+ const u = usageText()
344
+ if (u) return u + " " + cmds
345
+ return keyShortcut(props.api, "agent.cycle", "") + " agents " + cmds
346
+ })
347
+ const rightW = createMemo(() => visualWidth(rightText()))
348
+
349
+ // 输入框实际宽度 = 终端宽度 - 侧边栏(可见时 42) - 边距 4
350
+ // (与宿主 session 布局 contentWidth 口径一致;侧边栏可见性由本面板挂载状态驱动)
351
+ const inputW = createMemo(() => termW() - (props.signals.sidebarVisible() ? 42 : 0) - 4)
352
+
353
+ // 路径可用宽度 = 输入框宽度 - 统计宽度(精确) - 宿主右侧宽度(动态) - 布局开销;
354
+ // 低于 HIDE_PATH_BELOW 时整体隐藏路径(宽度归零),把空间让给统计与 commands
355
+ const dirDisplay = createMemo(() => {
356
+ const avail = inputW() - statsW() - rightW() - PATH_CHROME
357
+ if (avail < HIDE_PATH_BELOW) return ""
358
+ return truncateVisual(directory(), avail)
359
+ })
360
+ // 状态栏关闭(仅路径)时同样在极窄条件下隐藏路径
361
+ const dirFallback = createMemo(() => {
362
+ const avail = inputW() - rightW() - PATH_CHROME
363
+ if (avail < HIDE_PATH_BELOW) return ""
364
+ return truncateVisual(directory(), avail)
365
+ })
366
+
367
+ // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
368
+ onMount(() => {
369
+ try {
370
+ const v = props.api.kv.get<boolean>(`${KV_PREFIX}.section.bottom`, true)
371
+ props.signals.setSectionBottom(v !== false)
372
+ } catch {}
373
+ })
374
+
375
+ return (
376
+ <Show
377
+ when={props.signals.sectionBottom()}
378
+ fallback={<text fg={pal().muted}>{dirFallback()}</text>}
379
+ >
380
+ <box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
381
+ <text fg={pal().muted}>{dirDisplay()}</text>
382
+ <box flexDirection="row">
383
+ <text>
384
+ <For each={statsSegs()}>
385
+ {(sg) => <span style={{ fg: sg.color }}>{sg.text}</span>}
386
+ </For>
387
+ </text>
388
+ </box>
389
+ </box>
390
+ </Show>
391
+ )
392
+ }
393
+ function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
394
+ let lastSlotSid = ""
395
+ return {
396
+ order: 55,
397
+ slots: {
398
+ sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
399
+ // ── auto-clear override when the user navigates to a different main session ──
400
+ if (input.session_id !== lastSlotSid) {
401
+ lastSlotSid = input.session_id
402
+ if (signals.overrideSessionId()) {
403
+ signals.setOverrideSessionId(undefined)
404
+ api.kv.set("cache_panel.session", "")
405
+ }
406
+ }
407
+ return (
408
+ <TokenCachePanel
409
+ theme={ctx.theme.current}
410
+ api={api}
411
+ sessionId={input.session_id}
412
+ signals={signals}
413
+ />
414
+ )
415
+ },
416
+ },
417
+ }
418
+ }
419
+
420
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
421
+ // ── shared panel signals ──────────────────────────────────────
422
+ const [currencySymbol, setCurrencySymbol] = createSignal("$")
423
+ const [exchangeRate, setExchangeRate] = createSignal(1)
424
+ const [sectionDetail, setSectionDetail] = createSignal(true)
425
+ const [sectionModel, setSectionModel] = createSignal(true)
426
+ const [sectionDist, setSectionDist] = createSignal(true)
427
+ const [sectionSkills, setSectionSkills] = createSignal(true)
428
+ const [sectionBalance, setSectionBalance] = createSignal(true)
429
+ const [sectionBottom, setSectionBottom] = createSignal(true)
430
+ const [balanceRefresh, setBalanceRefresh] = createSignal(0)
431
+ const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek")
432
+ const [autoBalance, setAutoBalance] = createSignal(true)
433
+ const [balanceUnsupported, setBalanceUnsupported] = createSignal(false)
434
+ const [balanceCurrency, setBalanceCurrency] = createSignal("")
435
+ const [borderVisible, setBorderVisible] = createSignal(true)
436
+ const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
437
+ const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
438
+ // 侧边栏可见性(由 TokenCachePanel 挂载状态驱动):可见时宿主输入框宽度 = 终端宽 - 42 - 4
439
+ const [sidebarVisible, setSidebarVisible] = createSignal(false)
440
+
441
+ // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
442
+ // 避免重复请求导致两处余额不一致 ──
443
+ const [balanceState, setBalanceState] = createSignal<BalanceState>({
444
+ status: "idle", data: null, lastFetch: 0,
445
+ })
446
+ // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果
447
+ let balanceSeq = 0
448
+
449
+ const signals: PanelSignals = {
450
+ currencySymbol, setCurrencySymbol,
451
+ exchangeRate, setExchangeRate,
452
+ langCode, setLangCode,
453
+ sectionDetail, setSectionDetail,
454
+ sectionModel, setSectionModel,
455
+ sectionDist, setSectionDist,
456
+ sectionSkills, setSectionSkills,
457
+ sectionBalance, setSectionBalance,
458
+ sectionBottom, setSectionBottom,
459
+ balanceRefresh, setBalanceRefresh,
460
+ balanceProviderId, setBalanceProviderId,
461
+ autoBalance, setAutoBalance,
462
+ balanceUnsupported, setBalanceUnsupported,
463
+ balanceState,
464
+ balanceCurrency, setBalanceCurrency,
465
+ borderVisible, setBorderVisible,
466
+ overrideSessionId, setOverrideSessionId,
467
+ sidebarVisible, setSidebarVisible,
468
+ }
469
+
470
+ api.slots.register(createSidebarSlot(api, signals))
471
+
472
+ // 输入框 hint 行(session_prompt slot,replace 模式):
473
+ // 用宿主同一 Prompt 组件重渲染输入框,仅替换 hint 行左侧——
474
+ // 在路径与右侧 token/commands 提示之间插入 命中率 · 余额 · Tokens。
475
+ api.slots.register({
476
+ order: 55,
477
+ slots: {
478
+ session_prompt(
479
+ _ctx: TuiSlotContext,
480
+ input: {
481
+ session_id: string
482
+ visible?: boolean
483
+ disabled?: boolean
484
+ on_submit?: () => void
485
+ ref?: (ref: TuiPromptRef | undefined) => void
486
+ },
487
+ ): JSX.Element {
488
+ return (
489
+ <api.ui.Prompt
490
+ sessionID={input.session_id}
491
+ visible={input.visible}
492
+ disabled={input.disabled}
493
+ onSubmit={input.on_submit}
494
+ ref={input.ref}
495
+ hint={<BottomStatusBar api={api} signals={signals} sessionId={input.session_id} />}
496
+ />
497
+ )
498
+ },
499
+ },
500
+ })
501
+
502
+ // ── slash commands for runtime config ──
503
+ const KV_PREFIX = "cache_panel"
504
+
505
+ // ── 语言偏好恢复:KV 就绪后优先用户设置(/cache-lang),覆盖自动识别 ──
506
+ const restoreLang = () => {
507
+ try {
508
+ const saved = api.kv.get<string>(`${KV_PREFIX}.lang`)
509
+ if (saved && LANG_META.some((m) => m.code === saved)) setLangCode(saved as LangCode)
510
+ } catch {}
511
+ }
512
+ if (api.kv.ready) {
513
+ restoreLang()
514
+ } else {
515
+ const langTimer = setInterval(() => {
516
+ if (api.kv.ready) { clearInterval(langTimer); restoreLang() }
517
+ }, 10)
518
+ api.lifecycle.onDispose(() => clearInterval(langTimer))
519
+ }
520
+
521
+ const pollBalance = async () => {
522
+ const provider = getBalanceProvider(balanceProviderId())
523
+ // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config)
524
+ const key = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
525
+ || findOpencodeKey(api, provider)
526
+ if (balanceUnsupported()) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
527
+ if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return }
528
+ const now = Date.now()
529
+ const prev = balanceState()
530
+ // key 已更换(重新输入)→ 强制重新查询,绕过缓存
531
+ if (prev.status === "ok" && prev.key === key && now - prev.lastFetch < BALANCE_POLL_MS) return // cache still fresh
532
+ const seq = ++balanceSeq
533
+ setBalanceState({ ...prev, status: "loading", error: undefined, key })
534
+ const controller = new AbortController()
535
+ let timedOut = false
536
+ const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000)
537
+ try {
538
+ const data = await provider.fetchBalance(key, controller.signal)
539
+ clearTimeout(timer)
540
+ if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果
541
+ setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key })
542
+ } catch (err) {
543
+ clearTimeout(timer)
544
+ if (seq !== balanceSeq) return
545
+ const code = timedOut ? "TIMEOUT" : (err instanceof Error ? err.message : "")
546
+ // 失败时清空旧数据,避免显示过期余额
547
+ setBalanceState({ status: "error", data: null, lastFetch: 0, error: code, key })
548
+ }
549
+ }
550
+
551
+ // Re-fetch when the API key is (re)configured via /cache-balance-key.
552
+ // 注意:pollBalance 内部读写 balanceState 信号,若不做 untrack 包裹,
553
+ // effect 会追踪 balanceState 的变化并与 pollBalance 的 setBalanceState
554
+ // 形成无限循环(每次重跑都发起新的 fetch 请求)。
555
+ createEffect(() => {
556
+ void balanceRefresh()
557
+ untrack(() => { void pollBalance() })
558
+ })
559
+
560
+ // 定时轮询(5 分钟);随插件生命周期清理
561
+ const balanceTimer = setInterval(pollBalance, BALANCE_POLL_MS)
562
+ api.lifecycle.onDispose(() => clearInterval(balanceTimer))
563
+
564
+ /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */
565
+ const providerOptionTitle = (p: BalanceProvider, current?: string) => {
566
+ const t = createT(() => langCode())
567
+ const hasManual = !!api.kv.get<string>(`${KV_PREFIX}.balance.${p.id}.key`, "")
568
+ const hasAuto = !hasManual && !!findOpencodeKey(api, p)
569
+ const mark = hasManual
570
+ ? t("keyUser")
571
+ : hasAuto
572
+ ? t("keyOpenCode")
573
+ : t("keyNotSet")
574
+ return p.name + mark + (current && p.id === current ? " *" : "")
575
+ }
576
+
577
+ /** 弹出指定 provider API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */
578
+ const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => {
579
+ const t = createT(() => langCode())
580
+ const current = api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
581
+ const masked = maskKey(current)
582
+ dialog?.replace(() => (
583
+ <api.ui.DialogPrompt
584
+ title={provider.name}
585
+ description={() => <text>{t("balKeyPrompt", { p: provider.name })}</text>}
586
+ placeholder={provider.keyPlaceholder ?? "sk-..."}
587
+ value={masked}
588
+ onConfirm={(val) => {
589
+ const input = val.trim()
590
+ let key: string
591
+ if (input === "") {
592
+ key = ""
593
+ } else if (input.includes("*")) {
594
+ key = current
595
+ } else {
596
+ key = input
597
+ }
598
+ api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key)
599
+ setBalanceRefresh(v => v + 1)
600
+ if (key) {
601
+ api.ui.toast({ message: t("keySaved") })
602
+ } else {
603
+ api.ui.toast({ message: t("keyCleared") })
604
+ }
605
+ dialog?.clear()
606
+ }}
607
+ onCancel={() => dialog?.clear()}
608
+ />
609
+ ))
610
+ }
611
+
612
+ api.command?.register(() => [
613
+ {
614
+ title: "Cache: Set Currency",
615
+ value: "cache.currency",
616
+ description: "Change the currency unit for cost display",
617
+ slash: { name: "cache-currency" },
618
+ onSelect: (dialog) => {
619
+ dialog?.replace(() => (
620
+ <api.ui.DialogSelect
621
+ title="Select Currency"
622
+ options={Object.entries(CURRENCIES).map(([code, sym]) => ({
623
+ title: `${code} (${sym})`,
624
+ value: code,
625
+ }))}
626
+ onSelect={(opt) => {
627
+ const t = createT(() => langCode())
628
+ const sym = CURRENCIES[opt.value] ?? "$"
629
+ const defRate = DEFAULT_RATES[opt.value] ?? 1
630
+ api.kv.set(`${KV_PREFIX}.currency`, sym)
631
+ api.kv.set(`${KV_PREFIX}.rate`, defRate)
632
+ // 同步余额显示币种偏好:CNY/USD 原生直显,其余币种按汇率换算
633
+ api.kv.set(`${KV_PREFIX}.balance_currency`, opt.value)
634
+ signals.setBalanceCurrency(opt.value)
635
+ signals.setCurrencySymbol(sym)
636
+ signals.setExchangeRate(defRate)
637
+ api.ui.toast({ message: t("currencySet", { v: opt.value, s: sym, r: defRate }) })
638
+ dialog?.clear()
639
+ }}
640
+ />
641
+ ))
642
+ },
643
+ },
644
+ {
645
+ title: "Cache: Set Exchange Rate",
646
+ value: "cache.rate",
647
+ description: "Set the exchange rate multiplier for the selected currency",
648
+ slash: { name: "cache-rate" },
649
+ onSelect: (dialog) => {
650
+ dialog?.replace(() => (
651
+ <api.ui.DialogPrompt
652
+ title="Exchange Rate"
653
+ description={() => <text>Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)</text>}
654
+ placeholder="1.0"
655
+ value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
656
+ onConfirm={(val) => {
657
+ const t = createT(() => langCode())
658
+ const n = parseFloat(val)
659
+ if (n > 0) {
660
+ api.kv.set(`${KV_PREFIX}.rate`, n)
661
+ signals.setExchangeRate(n)
662
+ api.ui.toast({ message: t("rateSet", { r: n }) })
663
+ }
664
+ dialog?.clear()
665
+ }}
666
+ />
667
+ ))
668
+ },
669
+ },
670
+ {
671
+ title: "Cache: Toggle Section",
672
+ value: "cache.section",
673
+ description: "Show or hide a sidebar section",
674
+ slash: { name: "cache-section" },
675
+ onSelect: (dialog) => {
676
+ const t = createT(() => langCode())
677
+ const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
678
+ const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
679
+ const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
680
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
681
+ const balanceOn = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
682
+ const bottomOn = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
683
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
684
+ const labels: Record<string, string> = {
685
+ detail: t("secDetail"),
686
+ model: t("secModel"),
687
+ dist: t("distTitle"),
688
+ skills: t("secSkills"),
689
+ balance: t("secBalance"),
690
+ bottom: t("secBottom"),
691
+ border: t("secBorder"),
692
+ }
693
+ const optTitle = (label: string, on: boolean) => `${visualPadEnd(label, 15)}[${on ? "ON" : "OFF"}]`
694
+ dialog?.replace(() => (
695
+ <api.ui.DialogSelect
696
+ title={t("secToggle")}
697
+ options={[
698
+ { title: optTitle(labels.detail, detailOn), value: "detail" },
699
+ { title: optTitle(labels.model, modelOn), value: "model" },
700
+ { title: optTitle(labels.dist, distOn), value: "dist" },
701
+ { title: optTitle(labels.skills, skillsOn), value: "skills" },
702
+ { title: optTitle(labels.balance, balanceOn), value: "balance" },
703
+ { title: optTitle(labels.bottom, bottomOn), value: "bottom" },
704
+ { title: optTitle(labels.border, borderOn), value: "border" },
705
+ ]}
706
+ onSelect={(opt) => {
707
+ if (opt.value === "border") {
708
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
709
+ api.kv.set(`${KV_PREFIX}.border`, !cur)
710
+ signals.setBorderVisible(!cur)
711
+ api.ui.toast({ message: !cur ? t("borderShown") : t("borderHidden") })
712
+ } else {
713
+ const key = `${KV_PREFIX}.section.${opt.value}`
714
+ const cur = Boolean(api.kv.get(key, true))
715
+ api.kv.set(key, !cur)
716
+ if (opt.value === "detail") signals.setSectionDetail(!cur)
717
+ if (opt.value === "model") signals.setSectionModel(!cur)
718
+ if (opt.value === "dist") signals.setSectionDist(!cur)
719
+ if (opt.value === "skills") signals.setSectionSkills(!cur)
720
+ if (opt.value === "balance") signals.setSectionBalance(!cur)
721
+ if (opt.value === "bottom") signals.setSectionBottom(!cur)
722
+ const name = labels[opt.value] ?? opt.value
723
+ api.ui.toast({ message: t(!cur ? "sectionShown" : "sectionHidden", { s: name }) })
724
+ }
725
+ dialog?.clear()
726
+ }}
727
+ />
728
+ ))
729
+ },
730
+ },
731
+ {
732
+ title: "Cache: Show Config",
733
+ value: "cache.config",
734
+ description: "Display the current plugin configuration",
735
+ slash: { name: "cache-config" },
736
+ onSelect: (dialog) => {
737
+ const t = createT(() => langCode())
738
+ const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
739
+ const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
740
+ const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
741
+ const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
742
+ const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
743
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
744
+ const balance = Boolean(api.kv.get(`${KV_PREFIX}.section.balance`, true))
745
+ const bottom = Boolean(api.kv.get(`${KV_PREFIX}.section.bottom`, true))
746
+ const on = (v: boolean) => v ? "ON" : "OFF"
747
+ api.ui.toast({
748
+ title: t("panelConfigTitle"),
749
+ message: t("panelConfigMsg", {
750
+ c: sym, r: rate,
751
+ d: on(detail), m: on(model),
752
+ t: on(dist), k: on(skills),
753
+ b: on(balance), f: on(bottom),
754
+ }),
755
+ duration: 8000,
756
+ })
757
+ dialog?.clear()
758
+ },
759
+ },
760
+ {
761
+ title: "Cache: Switch Language",
762
+ value: "cache.lang",
763
+ description: "Switch between Chinese and English display",
764
+ slash: { name: "cache-lang" },
765
+ onSelect: (dialog) => {
766
+ const t = createT(() => langCode())
767
+ const cur = langCode()
768
+ dialog?.replace(() => (
769
+ <api.ui.DialogSelect
770
+ title={t("langTitle")}
771
+ options={LANG_META.map((m) => ({
772
+ title: `${visualPadEnd(m.label, 9)}${cur === m.code ? "\u2713" : ""}`,
773
+ value: m.code,
774
+ }))}
775
+ onSelect={(opt) => {
776
+ const code = opt.value as LangCode
777
+ api.kv.set(`${KV_PREFIX}.lang`, code)
778
+ setLangCode(code)
779
+ api.ui.toast({ message: t("langSwitched") })
780
+ dialog?.clear()
781
+ }}
782
+ />
783
+ ))
784
+ },
785
+ },
786
+ {
787
+ title: "Cache: Switch Balance Provider",
788
+ value: "cache.balance",
789
+ description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider",
790
+ slash: { name: "cache-balance" },
791
+ onSelect: (dialog) => {
792
+ const t = createT(() => langCode())
793
+ const current = signals.balanceProviderId()
794
+ const auto = signals.autoBalance()
795
+ const autoLabel = `${t("autoSwitchOpt")} [${auto ? "ON" : "OFF"}]`
796
+ dialog?.replace(() => (
797
+ <api.ui.DialogSelect
798
+ title={t("balProvTitle")}
799
+ options={[
800
+ {
801
+ title: autoLabel,
802
+ value: "__auto__",
803
+ },
804
+ ...balanceProviders.map((p) => ({
805
+ title: providerOptionTitle(p, current),
806
+ value: p.id,
807
+ })),
808
+ ]}
809
+ onSelect={(opt) => {
810
+ if (opt.value === "__auto__") {
811
+ const next = !auto
812
+ api.kv.set(`${KV_PREFIX}.balance.auto`, next)
813
+ signals.setAutoBalance(next)
814
+ api.ui.toast({ message: next ? t("autoSwitchOn") : t("autoSwitchOff") })
815
+ dialog?.clear()
816
+ } else {
817
+ const provider = getBalanceProvider(opt.value)
818
+ // 手动切换会关闭自动切换
819
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
820
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false)
821
+ signals.setBalanceProviderId(provider.id)
822
+ signals.setAutoBalance(false)
823
+ signals.setBalanceUnsupported(false)
824
+ // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额)
825
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
826
+ const hasKey = !!api.kv.get<string>(`${KV_PREFIX}.balance.${provider.id}.key`, "")
827
+ if (!hasKey) {
828
+ // 未配置 key → 进入设置流程(对话框保持打开等待输入)
829
+ promptBalanceKey(dialog, provider)
830
+ } else {
831
+ api.ui.toast({ message: t("providerManual", { p: provider.name }) })
832
+ dialog?.clear()
833
+ }
834
+ }
835
+ }}
836
+ />
837
+ ))
838
+ },
839
+ },
840
+ {
841
+ title: "Cache: Set Balance API Key",
842
+ value: "cache.balance.key",
843
+ description: "Select a provider and set its API key for balance display",
844
+ slash: { name: "cache-balance-key" },
845
+ onSelect: (dialog) => {
846
+ const t = createT(() => langCode())
847
+ // 步骤 1:选择 provider
848
+ dialog?.replace(() => (
849
+ <api.ui.DialogSelect
850
+ title={t("balSelectTitle")}
851
+ options={balanceProviders.map((p) => ({
852
+ title: providerOptionTitle(p),
853
+ value: p.id,
854
+ }))}
855
+ onSelect={(opt) => {
856
+ const provider = getBalanceProvider(opt.value)
857
+ // 手动指定 provider 会关闭自动切换
858
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id)
859
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false)
860
+ signals.setBalanceProviderId(provider.id)
861
+ signals.setAutoBalance(false)
862
+ // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额)
863
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1)
864
+ // 步骤 2:输入 key
865
+ promptBalanceKey(dialog, provider)
866
+ }}
867
+ />
868
+ ))
869
+ },
870
+ },
871
+ {
872
+ title: "Cache: Debug Skills Detection",
873
+ value: "cache.debug-skills",
874
+ description: "Dump all tool parts found in the current session for skill detection debugging",
875
+ slash: { name: "cache-debug-skills" },
876
+ onSelect: () => {
877
+ const t = createT(() => langCode())
878
+ const rt = api.route.current
879
+ if (rt.name !== "session" || !rt.params) {
880
+ api.ui.toast({ message: t("runInSession"), variant: "warning" })
881
+ return
882
+ }
883
+ const sid = String(rt.params.sessionID)
884
+ const msgs = api.state.session.messages(sid)
885
+ const byTool: Record<string, number> = {}
886
+ const skillParts: string[] = []
887
+ for (const msg of msgs) {
888
+ if (msg.role !== "assistant") continue
889
+ let parts: readonly any[] = []
890
+ try { parts = api.state.part(msg.id) } catch {}
891
+ for (const p of parts) {
892
+ if (p.type === "tool") {
893
+ const t = String(p.tool ?? "?")
894
+ byTool[t] = (byTool[t] ?? 0) + 1
895
+ if (t === "skill") {
896
+ const meta = p.state?.metadata
897
+ const rootMeta = p.metadata
898
+ 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)}"`)
899
+ }
900
+ }
901
+ }
902
+ }
903
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
904
+ const extra = skillParts.length > 0 ? "\n\nSkill parts:\n" + skillParts.join("\n") : "\n\n⚠ No skill tool parts found — AI may be reading SKILL.md instead. Try: 'Use the skill tool to load karpathy-guidelines'"
905
+ api.ui.toast({
906
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
907
+ message: summary + extra,
908
+ duration: 15000,
909
+ })
910
+ },
911
+ },
912
+ {
913
+ title: "Cache: Sub-Agent Stats",
914
+ value: "cache.session",
915
+ description: "View token cache statistics for a sub-agent by session ID",
916
+ slash: { name: "cache-session" },
917
+ onSelect: (dialog) => {
918
+ // ── 扫描当前主 session 的子代理 session ID 列表 ──
919
+ const rt = api.route.current
920
+ const parentSid = rt.name === "session" && rt.params ? String(rt.params.sessionID) : ""
921
+ const SUBAGENT_TOOLS = new Set(["task", "delegate", "call_omo_agent"])
922
+
923
+ interface ChildEntry { title: string; value: string; description: string }
924
+ const children: ChildEntry[] = []
925
+ if (parentSid) {
926
+ try {
927
+ const msgs = api.state.session.messages(parentSid)
928
+ for (const msg of msgs) {
929
+ if (msg.role !== "assistant") continue
930
+ let parts: readonly Part[] = []
931
+ try { parts = api.state.part(msg.id) } catch {}
932
+ for (const p of parts) {
933
+ if (p.type !== "tool") continue
934
+ const tool = String((p as ToolPart).tool ?? "")
935
+ if (!SUBAGENT_TOOLS.has(tool)) continue
936
+ const st = (p as any).state as Record<string, unknown> | undefined
937
+ const stMeta = st?.metadata as Record<string, unknown> | undefined
938
+ const subSid = stMeta?.session_id ?? stMeta?.sessionId
939
+ if (!subSid) continue
940
+ const sidStr = String(subSid)
941
+ const input = st?.input as Record<string, unknown> | undefined
942
+ const agent = String((p as any).subagent_type ?? input?.subagent_type ?? input?.category ?? tool)
943
+ const prompt = String(input?.prompt ?? "")
944
+ const desc = input?.description ? String(input.description) : ""
945
+ const title = desc || prompt.replace(/\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 40) || agent
946
+ children.push({ title, value: sidStr, description: `${agent} · ${sidStr.slice(0, 24)}…` })
947
+ }
948
+ }
949
+ } catch {}
950
+ }
951
+
952
+ // 去重
953
+ const seen = new Set<string>()
954
+ const unique = children.filter(c => { if (seen.has(c.value)) return false; seen.add(c.value); return true })
955
+
956
+ if (unique.length > 0) {
957
+ // ── 有子代理 → DialogSelect 列表选择 ──
958
+ const t = createT(() => langCode())
959
+ const currentSid = signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "")
960
+ const options = unique.map((c, i) => ({
961
+ title: `${i + 1}. ${c.title}`,
962
+ value: c.value,
963
+ description: c.description,
964
+ }))
965
+ // 首尾各放一个"回到主会话",长列表时顶部底部均可直达
966
+ const backValue = "__main__"
967
+ const backTitle = `\u2500 ${t("backToMainTitle")}`
968
+ options.unshift({ title: backTitle, value: backValue, description: "" })
969
+ options.push({ title: backTitle, value: backValue, description: "" })
970
+ const currentIdx = currentSid ? options.findIndex(o => o.value === currentSid) : -1
971
+ dialog?.replace(() => (
972
+ <api.ui.DialogSelect
973
+ title={t("subSelectTitle")}
974
+ options={options}
975
+ current={currentIdx >= 0 ? options[currentIdx].value : undefined}
976
+ onSelect={(opt) => {
977
+ if (opt.value === backValue) {
978
+ signals.setOverrideSessionId(undefined)
979
+ api.kv.set(`${KV_PREFIX}.session`, "")
980
+ api.ui.toast({ message: t("backToMain") })
981
+ } else {
982
+ signals.setOverrideSessionId(opt.value)
983
+ api.kv.set(`${KV_PREFIX}.session`, opt.value)
984
+ api.ui.toast({ message: t("subAgentSwitched", { s: opt.value.slice(0, 24) + "\u2026" }) })
985
+ }
986
+ dialog?.clear()
987
+ }}
988
+ />
989
+ ))
990
+ } else {
991
+ // ── 无子代理 → DialogPrompt 手动粘贴 ──
992
+ const t = createT(() => langCode())
993
+ dialog?.replace(() => (
994
+ <api.ui.DialogPrompt
995
+ title={signals.overrideSessionId() ? t("subSwitchTitle") : t("subViewTitle")}
996
+ description={() => <text>{t("subNoFound")}</text>}
997
+ placeholder="ses_..."
998
+ value={signals.overrideSessionId() ?? api.kv.get<string>(`${KV_PREFIX}.session`, "") ?? ""}
999
+ onConfirm={(val) => {
1000
+ const sid = val.trim()
1001
+ if (sid) {
1002
+ signals.setOverrideSessionId(sid)
1003
+ api.kv.set(`${KV_PREFIX}.session`, sid)
1004
+ api.ui.toast({ message: t("subAgentSwitched", { s: sid.slice(0, 24) + "\u2026" }) })
1005
+ }
1006
+ dialog?.clear()
1007
+ }}
1008
+ onCancel={() => dialog?.clear()}
1009
+ />
1010
+ ))
1011
+ }
1012
+ },
1013
+ },
1014
+ {
1015
+ title: "Cache: Back to Main",
1016
+ value: "cache.session.back",
1017
+ description: "Return to main session stats",
1018
+ slash: { name: "cache-session-back" },
1019
+ onSelect: (dialog) => {
1020
+ const t = createT(() => langCode())
1021
+ signals.setOverrideSessionId(undefined)
1022
+ api.kv.set(`${KV_PREFIX}.session`, "")
1023
+ api.ui.toast({ message: t("backToMain") })
1024
+ dialog?.clear()
1025
+ },
1026
+ },
1027
+ ])
1028
+ }
1029
+
1030
+ const mod: TuiPluginModule & { id: string } = {
1031
+ id: "opencode-visual-cache",
1032
+ tui,
1033
+ }
1034
+
1035
+ export default mod