opencode-visual-cache 1.2.15 → 1.2.16-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.
package/src/index.tsx CHANGED
@@ -1,1185 +1,1187 @@
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
- } from "@opencode-ai/plugin/tui"
12
- import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
13
- import type {
14
- Part,
15
- TextPart,
16
- ToolPart,
17
- FilePart,
18
- ReasoningPart,
19
- } from "@opencode-ai/sdk/v2"
20
- import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
21
- import { PLUGIN_VERSION } from "./_version"
22
-
23
- // ---------------------------------------------------------------------------
24
- // Helpers
25
- // ---------------------------------------------------------------------------
26
-
27
- // Bun / Node globals — available at runtime in the OpenCode TUI process
28
- declare const process: { env: Record<string, string | undefined> } | undefined
29
-
30
- // ── terminal-width helpers ────────────────────────────────────────
31
- // CJK characters occupy 2 terminal columns; padEnd/padStart count
32
- // string length (=1 per char), which breaks alignment with mixed text.
33
-
34
- function charColumns(c: string): number {
35
- const code = c.codePointAt(0) ?? 0
36
- if (code < 0x20) return 0 // control
37
- if (code < 0x7F) return 1 // ASCII
38
- if (code < 0xA0) return 0 // C1 controls
39
- // East-Asian wide / fullwidth ranges
40
- if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
41
- (code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
42
- (code >= 0xAC00 && code <= 0xD7A3) || // Hangul
43
- (code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
44
- (code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
45
- (code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
46
- (code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
47
- (code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
48
- (code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
49
- return 2
50
- return 1
51
- }
52
-
53
- function visualWidth(s: string): number {
54
- let w = 0; for (const c of s) w += charColumns(c); return w
55
- }
56
-
57
- function visualPadEnd(s: string, cols: number): string {
58
- const pad = cols - visualWidth(s)
59
- return pad > 0 ? s + " ".repeat(pad) : s
60
- }
61
-
62
- /** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
63
- function truncateVisual(s: string, maxCols: number): string {
64
- if (visualWidth(s) <= maxCols) return s
65
- let result = "", w = 0
66
- for (const c of s) {
67
- const cw = charColumns(c)
68
- if (w + cw > maxCols - 1) { result += "\u2026"; break }
69
- result += c; w += cw
70
- }
71
- return result
72
- }
73
-
74
- // ── language override (env: CACHE_TUI_LANG) ──
75
- const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
76
-
77
- // ── language ──────────────────────────────────────────────────────
78
-
79
- const LANG_ZH = DEBUG_LANG
80
- ? DEBUG_LANG === "zh"
81
- : (() => {
82
- try { return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh") }
83
- catch { return false }
84
- })()
85
-
86
- const ZH_T = {
87
- title: "缓存统计",
88
- hit: "命中率",
89
- totalHit: "总命中:",
90
- read: "缓存读:",
91
- write: "缓存写:",
92
- miss: "未命中:",
93
- out: "输出:",
94
- cost: "费用:",
95
- saved: "累计节省:",
96
- model: "模型:",
97
- provider: "提供商:",
98
- rate: "单价:",
99
- hitFolded: "命中",
100
- inputRate: "输入",
101
- cacheRate: "缓存",
102
- writeRate: "写入",
103
- noData: "等待缓存数据...",
104
- tok: "tok",
105
- distTitle: "估算 Token 分布",
106
- distSys: "系统提示:",
107
- distUser: "用户:",
108
- distAgent: "Agent 指令:",
109
- distTool: "Tool 调用:",
110
- distRes: "Tool 结果:",
111
- distTotal: "总计:",
112
- distOut: "输出:",
113
- secDetail: "明细",
114
- secModel: "模型",
115
- secSkills: "已加载技能",
116
- } as const
117
-
118
- const EN_T = {
119
- title: "Token Cache",
120
- hit: "Hit",
121
- totalHit: "Total Hit:",
122
- read: "Read:",
123
- write: "Write:",
124
- miss: "Miss:",
125
- out: "Out:",
126
- cost: "Cost:",
127
- saved: "Total Saved:",
128
- model: "Model:",
129
- provider: "Provider:",
130
- rate: "Rate:",
131
- hitFolded: "hit",
132
- inputRate: "in",
133
- cacheRate: "cache",
134
- writeRate: "write",
135
- noData: "Waiting for cache data...",
136
- tok: "tok",
137
- distTitle: "Estimated Token Dist.",
138
- distSys: "System:",
139
- distUser: "User:",
140
- distAgent: "Agent Instr:",
141
- distTool: "Tool Call:",
142
- distRes: "Tool Result:",
143
- distTotal: "Total:",
144
- distOut: "Output:",
145
- secDetail: "Detail",
146
- secModel: "Model",
147
- secSkills: "Loaded Skills",
148
- } as const
149
-
150
- // ── color helpers ────────────────────────────────────────────────
151
-
152
- /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
153
- function rgb(raw: unknown): { r: number; g: number; b: number } | null {
154
- if (typeof raw === "string" && raw.startsWith("#")) {
155
- const h = raw.slice(1)
156
- return {
157
- r: parseInt(h.slice(0, 2), 16),
158
- g: parseInt(h.slice(2, 4), 16),
159
- b: parseInt(h.slice(4, 6), 16),
160
- }
161
- }
162
- if (raw && typeof raw === "object") {
163
- const o = raw as Record<string, unknown>
164
- if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
165
- // RGBA channels may be 0-1 floats; detect and upscale.
166
- const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
167
- return {
168
- r: Math.round(o.r * scale),
169
- g: Math.round(o.g * scale),
170
- b: Math.round(o.b * scale),
171
- }
172
- }
173
- }
174
- return null
175
- }
176
-
177
- /** HSL saturation of an RGB color (0–1). */
178
- function saturation(r: number, g: number, b: number): number {
179
- const max = Math.max(r, g, b) / 255
180
- const min = Math.min(r, g, b) / 255
181
- const delta = max - min
182
- if (delta === 0) return 0
183
- const L = (max + min) / 2
184
- return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
185
- }
186
-
187
- /**
188
- * If the colour's saturation exceeds `maxSat`, pull it toward grey
189
- * until saturation drops to maxSat. Returns a hex string.
190
- */
191
- function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
192
- const c = rgb(raw)
193
- if (!c) return fallback
194
- const sat = saturation(c.r, c.g, c.b)
195
- if (sat <= maxSat) {
196
- // already muted return as hex
197
- return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
198
- }
199
- /**
200
- * Binary search for the optimal grey-mix ratio α (0…1).
201
- *
202
- * 12 iterations 1/2^12 1/4096 resolution. The downstream RGB
203
- * channels are only 0–255 (8 bit), so 8 iterations (1/256) would
204
- * technically suffice; 12 is intentionally over-budget the extra
205
- * precision costs almost nothing and guarantees the saturation probe
206
- * converges to within a fraction of an 8‑bit step, eliminating
207
- * colour banding in edge cases.
208
- */
209
- // BT.601 luma (perceptual brightness used as the grey anchor)
210
- const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
211
- let lo = 0, hi = 1
212
- for (let i = 0; i < 12; i++) {
213
- const mid = (lo + hi) / 2
214
- const nr = Math.round(c.r + (luma - c.r) * mid)
215
- const ng = Math.round(c.g + (luma - c.g) * mid)
216
- const nb = Math.round(c.b + (luma - c.b) * mid)
217
- if (saturation(nr, ng, nb) > maxSat) lo = mid
218
- else hi = mid
219
- }
220
- const nr = Math.round(c.r + (luma - c.r) * hi)
221
- const ng = Math.round(c.g + (luma - c.g) * hi)
222
- const nb = Math.round(c.b + (luma - c.b) * hi)
223
- return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
224
- }
225
-
226
- // Morandi fallbacks — used when a theme colour cannot be resolved
227
- const FALLBACK = {
228
- primary: "#8B9DAF",
229
- text: "#C5C5BB",
230
- muted: "#7A7A72",
231
- success: "#9CAF8B",
232
- warning: "#C5B88D",
233
- error: "#B08A8A",
234
- border: "#6B6B63",
235
- } as const
236
-
237
- /**
238
- * Desaturation ceiling for the Morandi-style palette.
239
- *
240
- * Morandi colours float around 0.15–0.30 saturation in HSL space.
241
- * 0.28 sits near the upper end of that range: it strips the aggressive
242
- * punch from high-saturation themes (Dracula, Solarized …) while
243
- * preserving enough colour identity that green / orange / red hit-rate
244
- * coding stays distinguishable.
245
- *
246
- * Lower more grey, harder to tell colours apart.
247
- * Higher → bright themes bleed through and defeat the muted look.
248
- */
249
- const MAX_SAT = 0.28
250
-
251
- function progressBar(percent: number, width: number): string {
252
- const clamped = Math.max(0, Math.min(100, percent))
253
- const filled = Math.round((clamped / 100) * width)
254
- const empty = Math.max(0, width - filled)
255
- return "\u2588".repeat(filled) + "\u2591".repeat(empty)
256
- }
257
-
258
- function fmt(n: number): string {
259
- if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
260
- if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
261
- return n.toLocaleString("en-US")
262
- }
263
-
264
- function num(v: unknown): number {
265
- return typeof v === "number" && Number.isFinite(v) ? v : 0
266
- }
267
-
268
- function fmtCost(n: number, symbol = "$", rate = 1): string {
269
- const v = n * rate
270
- if (v >= 1) return symbol + v.toFixed(2)
271
- if (v >= 0.01) return symbol + v.toFixed(3)
272
- return symbol + v.toFixed(4)
273
- }
274
-
275
- // ── token estimation ──
276
- // Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
277
- // chars per token) work well for natural language but systematically
278
- // under-count tokens in JSON and source code where every punctuation mark
279
- // tends to be its own token. Detect these cases and tighten the ratio.
280
- // See: GPT-4 / Claude tokenizer behaviour with structured text.
281
-
282
- function estimateTokens(text: string): number {
283
- if (!text || text.length === 0) return 0
284
- let ascii = 0
285
- let cjk = 0
286
- for (const c of text) {
287
- const code = c.codePointAt(0) ?? 0
288
- if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
289
- else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
290
- else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
291
- else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
292
- else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
293
- else ascii++
294
- }
295
-
296
- // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
297
- // ASCII chars/token for both JSON and source code — close to prose.
298
- // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
299
- // payloads, and systematically over-estimated token counts.
300
- const trimmed = text.trimStart()
301
- // Strip markdown code-fence prefix so that ```json … is detected as JSON
302
- const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
303
- const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
304
- && /"[^"]+"\s*:/.test(text)
305
- const codeLike = !jsonLike
306
- && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
307
-
308
- const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
309
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
310
- }
311
-
312
- interface TokenDist {
313
- system: number // UserMessage.system
314
- user: number // user message text/file parts
315
- agent: number // SubtaskPart.prompt + ReasoningPart.text
316
- toolCall: number // ToolPart.input (actual tool params)
317
- toolResult: number // ToolPart completed output / error
318
- output: number // AssistantMessage.tokens.output (fallback)
319
- apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
320
- apiInput: number // StepFinishPart.tokens.input (API exact total context)
321
- stepCost: number
322
- }
323
-
324
- // ---------------------------------------------------------------------------
325
- // Sidebar component
326
- // ---------------------------------------------------------------------------
327
-
328
- /** Signals shared between the TUI component and slash commands.
329
- * Created in the `tui` function scope so they do not survive module reload —
330
- * the component re-creates them on mount and restores user config from kv. */
331
- interface PanelSignals {
332
- currencySymbol: () => string
333
- setCurrencySymbol: (v: string) => void
334
- exchangeRate: () => number
335
- setExchangeRate: (v: number) => void
336
- langZH: () => boolean
337
- setLangZH: (v: boolean) => void
338
- sectionDetail: () => boolean
339
- setSectionDetail: (v: boolean) => void
340
- sectionModel: () => boolean
341
- setSectionModel: (v: boolean) => void
342
- sectionDist: () => boolean
343
- setSectionDist: (v: boolean) => void
344
- sectionSkills: () => boolean
345
- setSectionSkills: (v: boolean) => void
346
- borderVisible: () => boolean
347
- setBorderVisible: (v: boolean) => void
348
- }
349
-
350
- const CURRENCIES: Record<string, string> = {
351
- USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
352
- }
353
- /** Approximate USD exchange rates used as defaults when switching currency.
354
- * Users can override via /cache-rate. Last updated 2026-05. */
355
- const DEFAULT_RATES: Record<string, number> = {
356
- USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
357
- }
358
-
359
- const MIN_PANEL_WIDTH = 20
360
- const DEFAULT_PANEL_WIDTH = 26
361
-
362
- /** ── layout measurement constants (visual columns) ── */
363
- const LABEL_GAP = 1 // label(如 "Hit")后面的空格
364
- const BAR_BRACKETS = 2 // "[" + "]" 包围进度条
365
- const BAR_GAP = 1 // "]" 后面的空格
366
- const PCT_FIXED_WIDTH = 5 // "XX.X%" 固定 5 字符宽度
367
- const HEADER_PREFIX = 2 // 折叠态标题行:▶/▼ 图标 + 后面的空格
368
- const UNIT_GAP = 1 // 计量单位前的空格(如 "tok"
369
-
370
-
371
- function TokenCachePanel(props: {
372
- theme: TuiThemeCurrent
373
- api: TuiPluginApi
374
- sessionId: string
375
- signals: PanelSignals
376
- }): JSX.Element {
377
- const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
378
- const [open, setOpen] = createSignal(true)
379
- const [detailOpen, setDetailOpen] = createSignal(true)
380
- const [modelOpen, setModelOpen] = createSignal(true)
381
- const [distOpen, setDistOpen] = createSignal(false)
382
- const [skillsOpen, setSkillsOpen] = createSignal(true)
383
- let boxEl: any
384
-
385
- // ── shared signals (de-structured so internal code is unchanged) ──
386
- const {
387
- currencySymbol, setCurrencySymbol,
388
- exchangeRate, setExchangeRate,
389
- langZH, setLangZH,
390
- sectionDetail, setSectionDetail,
391
- sectionModel, setSectionModel,
392
- sectionDist, setSectionDist,
393
- sectionSkills, setSectionSkills,
394
- borderVisible, setBorderVisible,
395
- } = props.signals
396
-
397
- // ── reactive translation (follows langZH signal) ──
398
- const t = createMemo(() => langZH() ? ZH_T : EN_T)
399
-
400
- // ── scan session messages reactively ──
401
- // SolidJS createMemo re-evaluates whenever the underlying
402
- // api.state.session state changes no event listener needed.
403
-
404
- // ── distribution cache ────────────────────────────────────────
405
- // When data() re-computes before api.state.part() is warm (e.g. after
406
- // a view switch), hasDistData flips to false and the distribution
407
- // block disappears. Keep the last valid snapshot so the UI stays
408
- // stable until the next successful computation arrives.
409
- const [lastDist, setLastDist] = createSignal<TokenDist>({
410
- system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
411
- output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
412
- })
413
- const [lastHasDist, setLastHasDist] = createSignal(false)
414
-
415
- const [dataSignal, setDataSignal] = createSignal<any>({
416
- hitRate: 0, read: 0, write: 0, freshInput: 0, output: 0,
417
- cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
418
- hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
419
- providerName: "", sessionHitRate: 0,
420
- dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
421
- hasDistData: false,
422
- skills: [] as { name: string; tokens: number }[],
423
- hasSkills: false,
424
- })
425
- const [refreshTick, setRefreshTick] = createSignal(0)
426
-
427
- createEffect(() => {
428
- const sid = props.sessionId
429
- void refreshTick()
430
- void partVersion()
431
-
432
- // 自然追踪 messages 和 provider(SDK 数据就绪时自动重新执行)
433
- const msgs = props.api.state.session.messages(sid) as Message[]
434
- const session = typeof props.api.state.session.get === "function"
435
- ? props.api.state.session.get(sid)
436
- : undefined
437
-
438
- // 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
439
- // 若字段不存在(旧版本 SDK),降级到消息遍历累加
440
- let input = session?.tokens?.input ?? 0
441
- let read = session?.tokens?.cache?.read ?? 0
442
- let write = session?.tokens?.cache?.write ?? 0
443
- let output = session?.tokens?.output ?? 0
444
- let cost = session?.cost ?? 0
445
- let pid = session?.model?.providerID ?? ""
446
- let mid = session?.model?.id ?? ""
447
-
448
- const fallbackTokens = session?.tokens == null
449
- const fallbackCost = session?.cost == null
450
- const fallbackModel = !pid || !mid
451
-
452
- let prevMsgHitRate = -1, lastMsgHitRate = -1
453
- for (const msg of msgs) {
454
- if (msg.role !== "assistant") continue
455
- const t = (msg as AssistantMessage).tokens; if (!t) continue
456
- const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read)
457
- if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
458
- if (fallbackTokens) {
459
- input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output)
460
- }
461
- if (fallbackCost) {
462
- cost += num((msg as AssistantMessage).cost)
463
- }
464
- if (fallbackModel && (msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
465
- pid = (msg as AssistantMessage).providerID; mid = (msg as AssistantMessage).modelID
466
- }
467
- }
468
- let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0
469
- if (read > 0 && pid && mid && Array.isArray(props.api.state.provider)) for (const provider of props.api.state.provider) {
470
- if (provider.id !== pid) continue
471
- const model = provider.models[mid]; if (!model?.cost) continue
472
- inputRate = num(model.cost.input); cacheReadRate = num(model.cost.cache?.read); cacheWriteRate = num(model.cost.cache?.write)
473
- if (inputRate > cacheReadRate) saved = (read * (inputRate - cacheReadRate)) / 1_000_000
474
- break
475
- }
476
- const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
477
- const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
478
- const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
479
- const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
480
- const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
481
-
482
- // untrack 只包裹已知触发死锁的 API
483
- const distData = untrack(() => {
484
- let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
485
- let hasDistData = false
486
- const loadedSkills = new Map<string, { name: string; tokens: number }>()
487
- try {
488
- const cfg = props.api.state.config as Record<string, unknown>
489
- const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
490
- const agents = cfg?.agent as Record<string, unknown> | undefined
491
- const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
492
- const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
493
- if (sysPrompt) dist.system = estimateTokens(sysPrompt)
494
- let lastAssMsg: AssistantMessage | undefined
495
- for (const msg of msgs) {
496
- if (msg.role === "user") {
497
- const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system)
498
- let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
499
- for (const p of parts) {
500
- if (p.type === "text" && !(p as any).synthetic && !(p as any).ignored) dist.user += estimateTokens((p as any).text)
501
- else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) }
502
- }
503
- } else if (msg.role === "assistant") {
504
- const am = msg as AssistantMessage
505
- dist.output += num(am.tokens?.output)
506
- let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
507
- for (const p of parts) {
508
- if (p.type === "tool") {
509
- const tp = p as any; let rawInput = ""
510
- try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
511
- if (rawInput) dist.toolCall += estimateTokens(rawInput)
512
- if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
513
- else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
514
- if (tp.tool === "skill" && tp.state.status === "completed") {
515
- // TUI SDK strips tool metadata extract skill name from well-known output format.
516
- // Cross-validated against api.client.app.skills() when available.
517
- let name: string | undefined = tp.state.metadata?.name
518
- if (typeof name !== "string") {
519
- const m = typeof tp.state.output === "string"
520
- ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
521
- : null
522
- if (m) name = m[1].trim()
523
- }
524
- if (typeof name === "string") {
525
- const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0
526
- const existing = loadedSkills.get(name)
527
- if (!existing || existing.tokens < tokens) {
528
- loadedSkills.set(name, { name, tokens })
529
- }
530
- }
531
- }
532
- } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
533
- else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
534
- }
535
- }
536
- }
537
- // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
538
- for (let i = msgs.length - 1; i >= 0; i--) {
539
- if (msgs[i].role !== "assistant") continue
540
- const t = (msgs[i] as AssistantMessage).tokens
541
- if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
542
- }
543
- // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
544
- dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
545
- dist.apiOutput = num(lastAssMsg?.tokens?.output)
546
- hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
547
- } catch {}
548
- const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
549
- const skills = [...loadedSkills.values()]
550
- return { finalDist, finalHasDist, skills }
551
- })
552
-
553
- setDataSignal({
554
- hitRate, read, write, freshInput: input, output, cost, saved, model,
555
- inputRate, cacheReadRate, cacheWriteRate, hasPricing,
556
- hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
557
- trend, hasTrendData, providerName, sessionHitRate,
558
- dist: distData.finalDist, hasDistData: distData.finalHasDist,
559
- skills: distData.skills, hasSkills: distData.skills.length > 0,
560
- })
561
- })
562
-
563
- const data = createMemo(() => {
564
- return dataSignal()
565
- })
566
-
567
- // Persist the last valid distribution so that data() can fall back
568
- // to it while api.state.part() is re-hydrating after a view switch.
569
- createEffect(() => {
570
- const d = data()
571
- if (d.hasDistData) {
572
- setLastDist({ ...d.dist })
573
- setLastHasDist(true)
574
- // Also persist across component remounts (view switches)
575
- try { props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist }) } catch {}
576
- }
577
- })
578
-
579
- // ── token distribution (in-process via api.state.part) ──
580
- const [partVersion, setPartVersion] = createSignal(0)
581
-
582
- // Persist fold state to api.kv
583
- const KV_PREFIX = "cache_panel"
584
- const persistFold = (key: string, val: boolean) => {
585
- try { props.api.kv.set(`${KV_PREFIX}.${key}`, val) } catch {}
586
- }
587
-
588
- onMount(() => {
589
- // Reset panelWidth on (re)mount so the layout uses a clean
590
- // default until onSizeChange measures the live box dimensions.
591
- setPanelWidth(DEFAULT_PANEL_WIDTH)
592
-
593
- // Restore fold state from persisted storage (non-critical — fire and forget)
594
- try {
595
- setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)))
596
- setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
597
- setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
598
- setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
599
- setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
600
- } catch {}
601
-
602
- // Restore user config (currency, rate, section visibility).
603
- // Try synchronously first (kv is usually ready on mount), fall back to
604
- // polling if the module was reloaded and kv hasn't initialised yet.
605
- const doRestore = () => {
606
- try {
607
- const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
608
- const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
609
- if (typeof sym === "string") setCurrencySymbol(sym)
610
- if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
611
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
612
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
613
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
614
- setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
615
- const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
616
- setBorderVisible(bv !== false)
617
- // Restore language preference
618
- const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
619
- if (savedLang === "zh" || savedLang === "en") {
620
- setLangZH(savedLang === "zh")
621
- }
622
- // Restore distribution snapshot so the token distribution block
623
- // doesn't blank out while api.state.part() re-hydrates.
624
- const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
625
- if (cachedDist) {
626
- setLastDist(cachedDist)
627
- setLastHasDist(true)
628
- }
629
- } catch {
630
- // kv read failed — signals stay at defaults
631
- }
632
- // Re-measure panel width after config signals have settled
633
- if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
634
- setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width))
635
- }
636
- }
637
-
638
- if (props.api.kv.ready) {
639
- doRestore()
640
- } else {
641
- // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
642
- // on platforms where kv initialisation may be delayed (Linux single-thread
643
- // mode, session switch storms, etc.).
644
- const MAX_POLL = 100
645
- let tries = 0
646
- const pollRestore = () => {
647
- if (!props.api.kv.ready) {
648
- if (++tries > MAX_POLL) { doRestore(); return }
649
- setTimeout(pollRestore, 10)
650
- return
651
- }
652
- doRestore()
653
- }
654
- pollRestore()
655
- }
656
-
657
- // Debounce partVersion updates so that event bursts during session
658
- // switching / streaming don't cause data() to re-compute on every
659
- // single event (up to hundreds per second on Linux single-thread).
660
- let partTimer: ReturnType<typeof setTimeout> | undefined
661
- const bumpPartVersion = () => {
662
- clearTimeout(partTimer)
663
- partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100)
664
- }
665
- const unsubPart = props.api.event.on("message.part.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
666
- const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
667
- const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
668
- setRefreshTick(v => v + 1)
669
- onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
670
- })
671
-
672
- // ── colours ──
673
- // Pull from the current theme, auto-desaturate if too punchy,
674
- // fall back to Morandi when a key is missing from the theme.
675
- const pal = createMemo(() => {
676
- const t = props.theme as Record<string, unknown>
677
- const sat = (k: string, fb: string) => desaturateTo(t[k], MAX_SAT, fb)
678
- return {
679
- primary: sat("primary", FALLBACK.primary),
680
- text: sat("text", FALLBACK.text),
681
- muted: sat("textMuted", FALLBACK.muted),
682
- success: sat("success", FALLBACK.success),
683
- warning: sat("warning", FALLBACK.warning),
684
- error: sat("error", FALLBACK.error),
685
- border: sat("border", FALLBACK.border),
686
- }
687
- })
688
-
689
- const hitColor = createMemo(() => {
690
- const r = data().hitRate
691
- if (r >= 85) return pal().success
692
- if (r >= 70) return pal().warning
693
- return pal().error
694
- })
695
-
696
- /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
697
- const gutter = createMemo(() => borderVisible() ? 6 : 0)
698
-
699
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
700
- function trendLabel(t: number): string {
701
- return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
702
- }
703
-
704
- const barW = createMemo(() => {
705
- const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
706
- const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
707
- return Math.max(3, panelWidth() - overhead)
708
- })
709
- const bar = createMemo(() => progressBar(data().hitRate, barW()))
710
- const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
711
-
712
- // When border visibility changes the box dimensions shift, which
713
- // may not reliably trigger onSizeChange across (re)mount cycles.
714
- // Force panelWidth to resync with the live box after every change.
715
- createEffect(() => {
716
- borderVisible()
717
- if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
718
- const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
719
- setPanelWidth((prev) => (prev === w ? prev : w))
720
- }
721
- })
722
-
723
- // left-align label, right-align value — auto-fill space between
724
- const justify = (label: string, value: string, unit = ""): string => {
725
- const gauge = panelWidth() - gutter()
726
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
727
- const gap = Math.max(1, gauge - used)
728
- return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
729
- }
730
-
731
- return (
732
- <box
733
- border={borderVisible()}
734
- {...(borderVisible() ? { borderColor: pal().border } : {})}
735
- paddingTop={0}
736
- paddingBottom={0}
737
- paddingLeft={borderVisible() ? 2 : 0}
738
- paddingRight={borderVisible() ? 2 : 0}
739
- flexDirection="column"
740
- gap={0}
741
- ref={boxEl}
742
- onSizeChange={() => {
743
- // boxEl.width may be undefined before the first measurement — guard with 0
744
- const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH
745
- setPanelWidth((prev) => (prev === w ? prev : w))
746
- }}
747
- >
748
- {/* collapsible header */}
749
- <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
750
- <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
751
- <span style={{ fg: pal().primary }}>
752
- <b>{t().title}</b>
753
- <Show when={open()}>
754
- <span style={{ fg: pal().muted }}> (v{PLUGIN_VERSION})</span>
755
- </Show>
756
- </span>
757
- <Show when={!open() && data().hasData}>
758
- <Show when={data().hasTrendData}>
759
- <span>
760
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))}
761
- </span>
762
- <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
763
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
764
- {" "}{trendLabel(data().trend)}
765
- </span>
766
- </Show>
767
- <Show when={!data().hasTrendData}>
768
- <span>
769
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))}
770
- </span>
771
- <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
772
- </Show>
773
- </Show>
774
- </text>
775
-
776
- <Show when={open()}>
777
- <Show when={data().hasData} fallback={
778
- <>
779
- <text fg={pal().muted}>{sep()}</text>
780
- <text>
781
- <span style={{ fg: pal().muted }}>{"> "}</span>
782
- <span style={{ fg: pal().muted }}>{t().noData}</span>
783
- </text>
784
- </>
785
- }>
786
- <text fg={pal().muted}>{sep()}</text>
787
-
788
- {/* hit rate + bar — inline to avoid box spacing */}
789
- <text>
790
- <span style={{ fg: pal().text }}>{t().hit} </span>
791
- <span style={{ fg: hitColor() }}>[{bar()}] </span>
792
- <span style={{ fg: pal().text }}>{pct()}</span>
793
- <Show when={data().hasTrendData}>
794
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
795
- {" "}{trendLabel(data().trend)}
796
- </span>
797
- </Show>
798
- </text>
799
-
800
- {/* session cumulative hit rate */}
801
- <text fg={pal().muted}>
802
- {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
803
- </text>
804
-
805
- {/* ── detail section (collapsible, default open) ── */}
806
- <Show when={sectionDetail()}>
807
- <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
808
- <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
809
- <span style={{ fg: pal().primary }}><b>{t().secDetail}</b></span>
810
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))}</span>
811
- </text>
812
-
813
- <Show when={detailOpen()}>
814
- <Show when={data().read > 0}>
815
- <text fg={pal().muted}>
816
- {justify(t().read, fmt(data().read), t().tok)}
817
- </text>
818
- </Show>
819
- <Show when={data().write > 0}>
820
- <text fg={pal().muted}>
821
- {justify(t().write, fmt(data().write), t().tok)}
822
- </text>
823
- </Show>
824
- <text fg={pal().muted}>
825
- {justify(t().miss, fmt(data().freshInput), t().tok)}
826
- </text>
827
- <text fg={pal().muted}>
828
- {justify(t().out, fmt(data().output), t().tok)}
829
- </text>
830
- <Show when={data().saved > 0}>
831
- <text>
832
- <span style={{ fg: pal().muted }}>{t().saved}</span>
833
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
834
- <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
835
- </text>
836
- </Show>
837
- </Show>
838
- </Show>
839
-
840
- {/* ── model section (collapsible, default open) ── */}
841
- <Show when={sectionModel()}>
842
- {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
843
- <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
844
- <span style={{ fg: pal().primary }}><b>{t().secModel}</b></span>
845
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))}</span>
846
- </text>}
847
-
848
- <Show when={modelOpen()}>
849
- <text fg={pal().text}>
850
- {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
851
- </text>
852
- <Show when={data().providerName}>
853
- <text fg={pal().muted}>
854
- {justify(t().provider, data().providerName)}
855
- </text>
856
- </Show>
857
- <text fg={pal().muted}>
858
- {justify(t().model, data().model)}
859
- </text>
860
- <Show when={data().hasPricing}>
861
- <text fg={pal().muted}>
862
- {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)}
863
- </text>
864
- <Show when={data().cacheReadRate > 0}>
865
- <text fg={pal().muted}>
866
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)}
867
- </text>
868
- </Show>
869
- <Show when={data().cacheWriteRate > 0}>
870
- <text fg={pal().muted}>
871
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)}
872
- </text>
873
- </Show>
874
- </Show>
875
- </Show>
876
- </Show>
877
-
878
- {/* ── token distribution (collapsible, default closed) ── */}
879
- <Show when={sectionDist()}>
880
- <Show when={data().hasDistData}>
881
- {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
882
- <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
883
- <span style={{ fg: pal().primary }}><b>{t().distTitle}</b></span>
884
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))}</span>
885
- </text>}
886
- <Show when={distOpen()}>
887
- <Show when={data().dist.system > 0}>
888
- <text fg={pal().muted}>
889
- {justify(t().distSys, fmt(data().dist.system), t().tok)}
890
- </text>
891
- </Show>
892
- <Show when={data().dist.user > 0}>
893
- <text fg={pal().muted}>
894
- {justify(t().distUser, fmt(data().dist.user), t().tok)}
895
- </text>
896
- </Show>
897
- <Show when={data().dist.agent > 0}>
898
- <text fg={pal().muted}>
899
- {justify(t().distAgent, fmt(data().dist.agent), t().tok)}
900
- </text>
901
- </Show>
902
- <Show when={data().dist.toolCall > 0}>
903
- <text fg={pal().muted}>
904
- {justify(t().distTool, fmt(data().dist.toolCall), t().tok)}
905
- </text>
906
- </Show>
907
- <Show when={data().dist.toolResult > 0}>
908
- <text fg={pal().muted}>
909
- {justify(t().distRes, fmt(data().dist.toolResult), t().tok)}
910
- </text>
911
- </Show>
912
- <text fg={pal().text}>
913
- {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)}
914
- </text>
915
- </Show>
916
- </Show>
917
- </Show>
918
-
919
- {/* ── loaded skills (collapsible, default open) ── */}
920
- <Show when={sectionSkills()}>
921
- <Show when={data().hasSkills}>
922
- {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
923
- <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
924
- <span style={{ fg: pal().primary }}><b>{t().secSkills}</b></span>
925
- <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
926
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))}</span>
927
- </text>}
928
- <Show when={skillsOpen()}>
929
- {data().skills.map((sk: { name: string; tokens: number }) => {
930
- const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok)
931
- const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
932
- const label = truncateVisual(sk.name, maxLabel)
933
- return (
934
- <text fg={pal().muted}>
935
- {justify(label, fmt(sk.tokens), t().tok)}
936
- </text>
937
- )
938
- })}
939
- </Show>
940
- </Show>
941
- </Show>
942
- </Show>
943
- </Show>
944
- </box>
945
- )
946
- }
947
-
948
- // ---------------------------------------------------------------------------
949
- // Plugin entry
950
- // ---------------------------------------------------------------------------
951
-
952
- function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
953
- return {
954
- order: 55,
955
- slots: {
956
- sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
957
- return (
958
- <TokenCachePanel
959
- theme={ctx.theme.current}
960
- api={api}
961
- sessionId={input.session_id}
962
- signals={signals}
963
- />
964
- )
965
- },
966
- },
967
- }
968
- }
969
-
970
- const tui: TuiPlugin = async (api: TuiPluginApi) => {
971
- // ── shared panel signals ──────────────────────────────────────
972
- const [currencySymbol, setCurrencySymbol] = createSignal("$")
973
- const [exchangeRate, setExchangeRate] = createSignal(1)
974
- const [sectionDetail, setSectionDetail] = createSignal(true)
975
- const [sectionModel, setSectionModel] = createSignal(true)
976
- const [sectionDist, setSectionDist] = createSignal(true)
977
- const [sectionSkills, setSectionSkills] = createSignal(true)
978
- const [borderVisible, setBorderVisible] = createSignal(true)
979
- const [langZH, setLangZH] = createSignal(LANG_ZH)
980
-
981
- const signals: PanelSignals = {
982
- currencySymbol, setCurrencySymbol,
983
- exchangeRate, setExchangeRate,
984
- langZH, setLangZH,
985
- sectionDetail, setSectionDetail,
986
- sectionModel, setSectionModel,
987
- sectionDist, setSectionDist,
988
- sectionSkills, setSectionSkills,
989
- borderVisible, setBorderVisible,
990
- }
991
-
992
- api.slots.register(createSidebarSlot(api, signals))
993
-
994
- // ── slash commands for runtime config ──
995
- const KV_PREFIX = "cache_panel"
996
- api.command?.register(() => [
997
- {
998
- title: "Cache: Set Currency",
999
- value: "cache.currency",
1000
- description: "Change the currency unit for cost display",
1001
- slash: { name: "cache-currency" },
1002
- onSelect: (dialog) => {
1003
- dialog?.replace(() => (
1004
- <api.ui.DialogSelect
1005
- title="Select Currency"
1006
- options={Object.entries(CURRENCIES).map(([code, sym]) => ({
1007
- title: `${code} (${sym})`,
1008
- value: code,
1009
- }))}
1010
- onSelect={(opt) => {
1011
- const sym = CURRENCIES[opt.value] ?? "$"
1012
- const defRate = DEFAULT_RATES[opt.value] ?? 1
1013
- api.kv.set(`${KV_PREFIX}.currency`, sym)
1014
- api.kv.set(`${KV_PREFIX}.rate`, defRate)
1015
- signals.setCurrencySymbol(sym)
1016
- signals.setExchangeRate(defRate)
1017
- api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
1018
- dialog?.clear()
1019
- }}
1020
- />
1021
- ))
1022
- },
1023
- },
1024
- {
1025
- title: "Cache: Set Exchange Rate",
1026
- value: "cache.rate",
1027
- description: "Set the exchange rate multiplier for the selected currency",
1028
- slash: { name: "cache-rate" },
1029
- onSelect: (dialog) => {
1030
- dialog?.replace(() => (
1031
- <api.ui.DialogPrompt
1032
- title="Exchange Rate"
1033
- description={() => <text>Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)</text>}
1034
- placeholder="1.0"
1035
- value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
1036
- onConfirm={(val) => {
1037
- const n = parseFloat(val)
1038
- if (n > 0) {
1039
- api.kv.set(`${KV_PREFIX}.rate`, n)
1040
- signals.setExchangeRate(n)
1041
- api.ui.toast({ message: `Exchange rate set to ${n}` })
1042
- }
1043
- dialog?.clear()
1044
- }}
1045
- />
1046
- ))
1047
- },
1048
- },
1049
- {
1050
- title: "Cache: Toggle Section",
1051
- value: "cache.section",
1052
- description: "Show or hide a sidebar section",
1053
- slash: { name: "cache-section" },
1054
- onSelect: (dialog) => {
1055
- const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1056
- const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1057
- const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1058
- const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1059
- const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1060
- dialog?.replace(() => (
1061
- <api.ui.DialogSelect
1062
- title="Toggle Section"
1063
- options={[
1064
- { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
1065
- { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
1066
- { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1067
- { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
1068
- { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1069
- ]}
1070
- onSelect={(opt) => {
1071
- if (opt.value === "border") {
1072
- const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1073
- api.kv.set(`${KV_PREFIX}.border`, !cur)
1074
- signals.setBorderVisible(!cur)
1075
- api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
1076
- } else {
1077
- const key = `${KV_PREFIX}.section.${opt.value}`
1078
- const cur = Boolean(api.kv.get(key, true))
1079
- api.kv.set(key, !cur)
1080
- if (opt.value === "detail") signals.setSectionDetail(!cur)
1081
- if (opt.value === "model") signals.setSectionModel(!cur)
1082
- if (opt.value === "dist") signals.setSectionDist(!cur)
1083
- if (opt.value === "skills") signals.setSectionSkills(!cur)
1084
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1085
- }
1086
- dialog?.clear()
1087
- }}
1088
- />
1089
- ))
1090
- },
1091
- },
1092
- {
1093
- title: "Cache: Show Config",
1094
- value: "cache.config",
1095
- description: "Display the current plugin configuration",
1096
- slash: { name: "cache-config" },
1097
- onSelect: (dialog) => {
1098
- const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
1099
- const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
1100
- const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1101
- const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1102
- const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1103
- const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1104
- api.ui.toast({
1105
- title: "Cache Panel Config",
1106
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
1107
- duration: 8000,
1108
- })
1109
- dialog?.clear()
1110
- },
1111
- },
1112
- {
1113
- title: "Cache: Switch Language",
1114
- value: "cache.lang",
1115
- description: "Switch between Chinese and English display",
1116
- slash: { name: "cache-lang" },
1117
- onSelect: (dialog) => {
1118
- const cur = langZH()
1119
- dialog?.replace(() => (
1120
- <api.ui.DialogSelect
1121
- title="Display Language"
1122
- options={[
1123
- { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1124
- { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1125
- ]}
1126
- onSelect={(opt) => {
1127
- const zh = opt.value === "zh"
1128
- api.kv.set(`${KV_PREFIX}.lang`, opt.value)
1129
- setLangZH(zh)
1130
- api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" })
1131
- dialog?.clear()
1132
- }}
1133
- />
1134
- ))
1135
- },
1136
- },
1137
- {
1138
- title: "Cache: Debug Skills Detection",
1139
- value: "cache.debug-skills",
1140
- description: "Dump all tool parts found in the current session for skill detection debugging",
1141
- slash: { name: "cache-debug-skills" },
1142
- onSelect: () => {
1143
- const rt = api.route.current
1144
- if (rt.name !== "session" || !rt.params) {
1145
- api.ui.toast({ message: "Please run this command inside a session", variant: "warning" })
1146
- return
1147
- }
1148
- const sid = String(rt.params.sessionID)
1149
- const msgs = api.state.session.messages(sid)
1150
- const byTool: Record<string, number> = {}
1151
- const skillParts: string[] = []
1152
- for (const msg of msgs) {
1153
- if (msg.role !== "assistant") continue
1154
- let parts: readonly any[] = []
1155
- try { parts = api.state.part(msg.id) } catch {}
1156
- for (const p of parts) {
1157
- if (p.type === "tool") {
1158
- const t = String(p.tool ?? "?")
1159
- byTool[t] = (byTool[t] ?? 0) + 1
1160
- if (t === "skill") {
1161
- const meta = p.state?.metadata
1162
- const rootMeta = p.metadata
1163
- 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)}"`)
1164
- }
1165
- }
1166
- }
1167
- }
1168
- const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
1169
- 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'"
1170
- api.ui.toast({
1171
- title: `Tool Summary (${Object.keys(byTool).length} types)`,
1172
- message: summary + extra,
1173
- duration: 15000,
1174
- })
1175
- },
1176
- },
1177
- ])
1178
- }
1179
-
1180
- const mod: TuiPluginModule & { id: string } = {
1181
- id: "opencode-visual-cache",
1182
- tui,
1183
- }
1184
-
1185
- 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
+ } from "@opencode-ai/plugin/tui"
12
+ import type {
13
+ UserMessage,
14
+ AssistantMessage,
15
+ Message,
16
+ Part,
17
+ TextPart,
18
+ ToolPart,
19
+ FilePart,
20
+ ReasoningPart,
21
+ } from "@opencode-ai/sdk/v2"
22
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
23
+ import { PLUGIN_VERSION } from "./_version"
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ // Bun / Node globals — available at runtime in the OpenCode TUI process
30
+ declare const process: { env: Record<string, string | undefined> } | undefined
31
+
32
+ // ── terminal-width helpers ────────────────────────────────────────
33
+ // CJK characters occupy 2 terminal columns; padEnd/padStart count
34
+ // string length (=1 per char), which breaks alignment with mixed text.
35
+
36
+ function charColumns(c: string): number {
37
+ const code = c.codePointAt(0) ?? 0
38
+ if (code < 0x20) return 0 // control
39
+ if (code < 0x7F) return 1 // ASCII
40
+ if (code < 0xA0) return 0 // C1 controls
41
+ // East-Asian wide / fullwidth ranges
42
+ if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
43
+ (code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
44
+ (code >= 0xAC00 && code <= 0xD7A3) || // Hangul
45
+ (code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
46
+ (code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
47
+ (code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
48
+ (code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
49
+ (code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
50
+ (code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
51
+ return 2
52
+ return 1
53
+ }
54
+
55
+ function visualWidth(s: string): number {
56
+ let w = 0; for (const c of s) w += charColumns(c); return w
57
+ }
58
+
59
+ function visualPadEnd(s: string, cols: number): string {
60
+ const pad = cols - visualWidth(s)
61
+ return pad > 0 ? s + " ".repeat(pad) : s
62
+ }
63
+
64
+ /** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
65
+ function truncateVisual(s: string, maxCols: number): string {
66
+ if (visualWidth(s) <= maxCols) return s
67
+ let result = "", w = 0
68
+ for (const c of s) {
69
+ const cw = charColumns(c)
70
+ if (w + cw > maxCols - 1) { result += "\u2026"; break }
71
+ result += c; w += cw
72
+ }
73
+ return result
74
+ }
75
+
76
+ // ── language override (env: CACHE_TUI_LANG) ──
77
+ const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
78
+
79
+ // ── language ──────────────────────────────────────────────────────
80
+
81
+ const LANG_ZH = DEBUG_LANG
82
+ ? DEBUG_LANG === "zh"
83
+ : (() => {
84
+ try { return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh") }
85
+ catch { return false }
86
+ })()
87
+
88
+ const ZH_T = {
89
+ title: "缓存统计",
90
+ hit: "命中率",
91
+ totalHit: "总命中:",
92
+ read: "缓存读:",
93
+ write: "缓存写:",
94
+ miss: "未命中:",
95
+ out: "输出:",
96
+ cost: "费用:",
97
+ saved: "累计节省:",
98
+ model: "模型:",
99
+ provider: "提供商:",
100
+ rate: "单价:",
101
+ hitFolded: "命中",
102
+ inputRate: "输入",
103
+ cacheRate: "缓存",
104
+ writeRate: "写入",
105
+ noData: "等待缓存数据...",
106
+ tok: "tok",
107
+ distTitle: "估算 Token 分布",
108
+ distSys: "系统提示:",
109
+ distUser: "用户:",
110
+ distAgent: "Agent 指令:",
111
+ distTool: "Tool 调用:",
112
+ distRes: "Tool 结果:",
113
+ distTotal: "总计:",
114
+ distOut: "输出:",
115
+ secDetail: "明细",
116
+ secModel: "模型",
117
+ secSkills: "已加载技能",
118
+ } as const
119
+
120
+ const EN_T = {
121
+ title: "Token Cache",
122
+ hit: "Hit",
123
+ totalHit: "Total Hit:",
124
+ read: "Read:",
125
+ write: "Write:",
126
+ miss: "Miss:",
127
+ out: "Out:",
128
+ cost: "Cost:",
129
+ saved: "Total Saved:",
130
+ model: "Model:",
131
+ provider: "Provider:",
132
+ rate: "Rate:",
133
+ hitFolded: "hit",
134
+ inputRate: "in",
135
+ cacheRate: "cache",
136
+ writeRate: "write",
137
+ noData: "Waiting for cache data...",
138
+ tok: "tok",
139
+ distTitle: "Estimated Token Dist.",
140
+ distSys: "System:",
141
+ distUser: "User:",
142
+ distAgent: "Agent Instr:",
143
+ distTool: "Tool Call:",
144
+ distRes: "Tool Result:",
145
+ distTotal: "Total:",
146
+ distOut: "Output:",
147
+ secDetail: "Detail",
148
+ secModel: "Model",
149
+ secSkills: "Loaded Skills",
150
+ } as const
151
+
152
+ // ── color helpers ────────────────────────────────────────────────
153
+
154
+ /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
155
+ function rgb(raw: unknown): { r: number; g: number; b: number } | null {
156
+ if (typeof raw === "string" && raw.startsWith("#")) {
157
+ const h = raw.slice(1)
158
+ return {
159
+ r: parseInt(h.slice(0, 2), 16),
160
+ g: parseInt(h.slice(2, 4), 16),
161
+ b: parseInt(h.slice(4, 6), 16),
162
+ }
163
+ }
164
+ if (raw && typeof raw === "object") {
165
+ const o = raw as Record<string, unknown>
166
+ if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
167
+ // RGBA channels may be 0-1 floats; detect and upscale.
168
+ const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
169
+ return {
170
+ r: Math.round(o.r * scale),
171
+ g: Math.round(o.g * scale),
172
+ b: Math.round(o.b * scale),
173
+ }
174
+ }
175
+ }
176
+ return null
177
+ }
178
+
179
+ /** HSL saturation of an RGB color (0–1). */
180
+ function saturation(r: number, g: number, b: number): number {
181
+ const max = Math.max(r, g, b) / 255
182
+ const min = Math.min(r, g, b) / 255
183
+ const delta = max - min
184
+ if (delta === 0) return 0
185
+ const L = (max + min) / 2
186
+ return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
187
+ }
188
+
189
+ /**
190
+ * If the colour's saturation exceeds `maxSat`, pull it toward grey
191
+ * until saturation drops to maxSat. Returns a hex string.
192
+ */
193
+ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
194
+ const c = rgb(raw)
195
+ if (!c) return fallback
196
+ const sat = saturation(c.r, c.g, c.b)
197
+ if (sat <= maxSat) {
198
+ // already muted — return as hex
199
+ return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
200
+ }
201
+ /**
202
+ * Binary search for the optimal grey-mix ratio α (0…1).
203
+ *
204
+ * 12 iterations → 1/2^12 1/4096 resolution. The downstream RGB
205
+ * channels are only 0–255 (8 bit), so 8 iterations (1/256) would
206
+ * technically suffice; 12 is intentionally over-budget the extra
207
+ * precision costs almost nothing and guarantees the saturation probe
208
+ * converges to within a fraction of an 8‑bit step, eliminating
209
+ * colour banding in edge cases.
210
+ */
211
+ // BT.601 luma (perceptual brightness used as the grey anchor)
212
+ const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
213
+ let lo = 0, hi = 1
214
+ for (let i = 0; i < 12; i++) {
215
+ const mid = (lo + hi) / 2
216
+ const nr = Math.round(c.r + (luma - c.r) * mid)
217
+ const ng = Math.round(c.g + (luma - c.g) * mid)
218
+ const nb = Math.round(c.b + (luma - c.b) * mid)
219
+ if (saturation(nr, ng, nb) > maxSat) lo = mid
220
+ else hi = mid
221
+ }
222
+ const nr = Math.round(c.r + (luma - c.r) * hi)
223
+ const ng = Math.round(c.g + (luma - c.g) * hi)
224
+ const nb = Math.round(c.b + (luma - c.b) * hi)
225
+ return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
226
+ }
227
+
228
+ // Morandi fallbacks — used when a theme colour cannot be resolved
229
+ const FALLBACK = {
230
+ primary: "#8B9DAF",
231
+ text: "#C5C5BB",
232
+ muted: "#7A7A72",
233
+ success: "#9CAF8B",
234
+ warning: "#C5B88D",
235
+ error: "#B08A8A",
236
+ border: "#6B6B63",
237
+ } as const
238
+
239
+ /**
240
+ * Desaturation ceiling for the Morandi-style palette.
241
+ *
242
+ * Morandi colours float around 0.15–0.30 saturation in HSL space.
243
+ * 0.28 sits near the upper end of that range: it strips the aggressive
244
+ * punch from high-saturation themes (Dracula, Solarized …) while
245
+ * preserving enough colour identity that green / orange / red hit-rate
246
+ * coding stays distinguishable.
247
+ *
248
+ * Lower → more grey, harder to tell colours apart.
249
+ * Higher → bright themes bleed through and defeat the muted look.
250
+ */
251
+ const MAX_SAT = 0.28
252
+
253
+ function progressBar(percent: number, width: number): string {
254
+ const clamped = Math.max(0, Math.min(100, percent))
255
+ const filled = Math.round((clamped / 100) * width)
256
+ const empty = Math.max(0, width - filled)
257
+ return "\u2588".repeat(filled) + "\u2591".repeat(empty)
258
+ }
259
+
260
+ function fmt(n: number): string {
261
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
262
+ if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
263
+ return n.toLocaleString("en-US")
264
+ }
265
+
266
+ function num(v: unknown): number {
267
+ return typeof v === "number" && Number.isFinite(v) ? v : 0
268
+ }
269
+
270
+ function fmtCost(n: number, symbol = "$", rate = 1): string {
271
+ const v = n * rate
272
+ if (v >= 1) return symbol + v.toFixed(2)
273
+ if (v >= 0.01) return symbol + v.toFixed(3)
274
+ return symbol + v.toFixed(4)
275
+ }
276
+
277
+ // ── token estimation ──
278
+ // Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
279
+ // chars per token) work well for natural language but systematically
280
+ // under-count tokens in JSON and source code where every punctuation mark
281
+ // tends to be its own token. Detect these cases and tighten the ratio.
282
+ // See: GPT-4 / Claude tokenizer behaviour with structured text.
283
+
284
+ function estimateTokens(text: string): number {
285
+ if (!text || text.length === 0) return 0
286
+ let ascii = 0
287
+ let cjk = 0
288
+ for (const c of text) {
289
+ const code = c.codePointAt(0) ?? 0
290
+ if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
291
+ else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
292
+ else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
293
+ else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
294
+ else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
295
+ else ascii++
296
+ }
297
+
298
+ // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
299
+ // ASCII chars/token for both JSON and source code close to prose.
300
+ // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
301
+ // payloads, and systematically over-estimated token counts.
302
+ const trimmed = text.trimStart()
303
+ // Strip markdown code-fence prefix so that ```json … is detected as JSON
304
+ const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
305
+ const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
306
+ && /"[^"]+"\s*:/.test(text)
307
+ const codeLike = !jsonLike
308
+ && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
309
+
310
+ const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
311
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
312
+ }
313
+
314
+ interface TokenDist {
315
+ system: number // UserMessage.system
316
+ user: number // user message text/file parts
317
+ agent: number // SubtaskPart.prompt + ReasoningPart.text
318
+ toolCall: number // ToolPart.input (actual tool params)
319
+ toolResult: number // ToolPart completed output / error
320
+ output: number // AssistantMessage.tokens.output (fallback)
321
+ apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
322
+ apiInput: number // StepFinishPart.tokens.input (API exact total context)
323
+ stepCost: number
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Sidebar component
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /** Signals shared between the TUI component and slash commands.
331
+ * Created in the `tui` function scope so they do not survive module reload —
332
+ * the component re-creates them on mount and restores user config from kv. */
333
+ interface PanelSignals {
334
+ currencySymbol: () => string
335
+ setCurrencySymbol: (v: string) => void
336
+ exchangeRate: () => number
337
+ setExchangeRate: (v: number) => void
338
+ langZH: () => boolean
339
+ setLangZH: (v: boolean) => void
340
+ sectionDetail: () => boolean
341
+ setSectionDetail: (v: boolean) => void
342
+ sectionModel: () => boolean
343
+ setSectionModel: (v: boolean) => void
344
+ sectionDist: () => boolean
345
+ setSectionDist: (v: boolean) => void
346
+ sectionSkills: () => boolean
347
+ setSectionSkills: (v: boolean) => void
348
+ borderVisible: () => boolean
349
+ setBorderVisible: (v: boolean) => void
350
+ }
351
+
352
+ const CURRENCIES: Record<string, string> = {
353
+ USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
354
+ }
355
+ /** Approximate USD exchange rates — used as defaults when switching currency.
356
+ * Users can override via /cache-rate. Last updated 2026-05. */
357
+ const DEFAULT_RATES: Record<string, number> = {
358
+ USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
359
+ }
360
+
361
+ const MIN_PANEL_WIDTH = 20
362
+ const DEFAULT_PANEL_WIDTH = 26
363
+
364
+ /** ── layout measurement constants (visual columns) ── */
365
+ const LABEL_GAP = 1 // label(如 "Hit")后面的空格
366
+ const BAR_BRACKETS = 2 // "[" + "]" 包围进度条
367
+ const BAR_GAP = 1 // "]" 后面的空格
368
+ const PCT_FIXED_WIDTH = 5 // "XX.X%" 固定 5 字符宽度
369
+ const HEADER_PREFIX = 2 // 折叠态标题行:▶/▼ 图标 + 后面的空格
370
+ const UNIT_GAP = 1 // 计量单位前的空格(如 "tok")
371
+
372
+
373
+ function TokenCachePanel(props: {
374
+ theme: TuiThemeCurrent
375
+ api: TuiPluginApi
376
+ sessionId: string
377
+ signals: PanelSignals
378
+ }): JSX.Element {
379
+ const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
380
+ const [open, setOpen] = createSignal(true)
381
+ const [detailOpen, setDetailOpen] = createSignal(true)
382
+ const [modelOpen, setModelOpen] = createSignal(true)
383
+ const [distOpen, setDistOpen] = createSignal(false)
384
+ const [skillsOpen, setSkillsOpen] = createSignal(true)
385
+ let boxEl: any
386
+
387
+ // ── shared signals (de-structured so internal code is unchanged) ──
388
+ const {
389
+ currencySymbol, setCurrencySymbol,
390
+ exchangeRate, setExchangeRate,
391
+ langZH, setLangZH,
392
+ sectionDetail, setSectionDetail,
393
+ sectionModel, setSectionModel,
394
+ sectionDist, setSectionDist,
395
+ sectionSkills, setSectionSkills,
396
+ borderVisible, setBorderVisible,
397
+ } = props.signals
398
+
399
+ // ── reactive translation (follows langZH signal) ──
400
+ const t = createMemo(() => langZH() ? ZH_T : EN_T)
401
+
402
+ // ── scan session messages reactively ──
403
+ // SolidJS createMemo re-evaluates whenever the underlying
404
+ // api.state.session state changes — no event listener needed.
405
+
406
+ // ── distribution cache ────────────────────────────────────────
407
+ // When data() re-computes before api.state.part() is warm (e.g. after
408
+ // a view switch), hasDistData flips to false and the distribution
409
+ // block disappears. Keep the last valid snapshot so the UI stays
410
+ // stable until the next successful computation arrives.
411
+ const [lastDist, setLastDist] = createSignal<TokenDist>({
412
+ system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
413
+ output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
414
+ })
415
+ const [lastHasDist, setLastHasDist] = createSignal(false)
416
+
417
+ const [dataSignal, setDataSignal] = createSignal<any>({
418
+ hitRate: 0, read: 0, write: 0, freshInput: 0, output: 0,
419
+ cost: 0, saved: 0, model: "", inputRate: 0, cacheReadRate: 0, cacheWriteRate: 0,
420
+ hasPricing: false, hasData: false, trend: 0, hasTrendData: false,
421
+ providerName: "", sessionHitRate: 0,
422
+ dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
423
+ hasDistData: false,
424
+ skills: [] as { name: string; tokens: number }[],
425
+ hasSkills: false,
426
+ })
427
+ const [refreshTick, setRefreshTick] = createSignal(0)
428
+
429
+ createEffect(() => {
430
+ const sid = props.sessionId
431
+ void refreshTick()
432
+ void partVersion()
433
+
434
+ // 自然追踪 messages provider(SDK 数据就绪时自动重新执行)
435
+ const msgs = props.api.state.session.messages(sid) as Message[]
436
+ const session = typeof props.api.state.session.get === "function"
437
+ ? props.api.state.session.get(sid)
438
+ : undefined
439
+
440
+ // 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
441
+ // 若字段不存在(旧版本 SDK),降级到消息遍历累加
442
+ let input = session?.tokens?.input ?? 0
443
+ let read = session?.tokens?.cache?.read ?? 0
444
+ let write = session?.tokens?.cache?.write ?? 0
445
+ let output = session?.tokens?.output ?? 0
446
+ let cost = session?.cost ?? 0
447
+ let pid = session?.model?.providerID ?? ""
448
+ let mid = session?.model?.id ?? ""
449
+
450
+ const fallbackTokens = session?.tokens == null
451
+ const fallbackCost = session?.cost == null
452
+ const fallbackModel = !pid || !mid
453
+
454
+ let prevMsgHitRate = -1, lastMsgHitRate = -1
455
+ for (const msg of msgs) {
456
+ if (msg.role !== "assistant") continue
457
+ const t = (msg as AssistantMessage).tokens; if (!t) continue
458
+ const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read)
459
+ if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
460
+ if (fallbackTokens) {
461
+ input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output)
462
+ }
463
+ if (fallbackCost) {
464
+ cost += num((msg as AssistantMessage).cost)
465
+ }
466
+ if (fallbackModel && (msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
467
+ pid = (msg as AssistantMessage).providerID; mid = (msg as AssistantMessage).modelID
468
+ }
469
+ }
470
+ let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0
471
+ if (read > 0 && pid && mid && Array.isArray(props.api.state.provider)) for (const provider of props.api.state.provider) {
472
+ if (provider.id !== pid) continue
473
+ const model = provider.models[mid]; if (!model?.cost) continue
474
+ inputRate = num(model.cost.input); cacheReadRate = num(model.cost.cache?.read); cacheWriteRate = num(model.cost.cache?.write)
475
+ if (inputRate > cacheReadRate) saved = (read * (inputRate - cacheReadRate)) / 1_000_000
476
+ break
477
+ }
478
+ const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
479
+ const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
480
+ const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
481
+ const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
482
+ const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || ""
483
+
484
+ // untrack 只包裹已知触发死锁的 API
485
+ const distData = untrack(() => {
486
+ let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
487
+ let hasDistData = false
488
+ const loadedSkills = new Map<string, { name: string; tokens: number }>()
489
+ try {
490
+ const cfg = props.api.state.config as Record<string, unknown>
491
+ const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
492
+ const agents = cfg?.agent as Record<string, unknown> | undefined
493
+ const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
494
+ const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
495
+ if (sysPrompt) dist.system = estimateTokens(sysPrompt)
496
+ let lastAssMsg: AssistantMessage | undefined
497
+ for (const msg of msgs) {
498
+ if (msg.role === "user") {
499
+ const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system)
500
+ let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
501
+ for (const p of parts) {
502
+ if (p.type === "text" && !(p as any).synthetic && !(p as any).ignored) dist.user += estimateTokens((p as any).text)
503
+ else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) }
504
+ }
505
+ } else if (msg.role === "assistant") {
506
+ const am = msg as AssistantMessage
507
+ dist.output += num(am.tokens?.output)
508
+ let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
509
+ for (const p of parts) {
510
+ if (p.type === "tool") {
511
+ const tp = p as any; let rawInput = ""
512
+ try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
513
+ if (rawInput) dist.toolCall += estimateTokens(rawInput)
514
+ if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
515
+ else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
516
+ if (tp.tool === "skill" && tp.state.status === "completed") {
517
+ // TUI SDK strips tool metadata — extract skill name from well-known output format.
518
+ // Cross-validated against api.client.app.skills() when available.
519
+ let name: string | undefined = tp.state.metadata?.name
520
+ if (typeof name !== "string") {
521
+ const m = typeof tp.state.output === "string"
522
+ ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
523
+ : null
524
+ if (m) name = m[1].trim()
525
+ }
526
+ if (typeof name === "string") {
527
+ const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0
528
+ const existing = loadedSkills.get(name)
529
+ if (!existing || existing.tokens < tokens) {
530
+ loadedSkills.set(name, { name, tokens })
531
+ }
532
+ }
533
+ }
534
+ } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
535
+ else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
536
+ }
537
+ }
538
+ }
539
+ // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
540
+ for (let i = msgs.length - 1; i >= 0; i--) {
541
+ if (msgs[i].role !== "assistant") continue
542
+ const t = (msgs[i] as AssistantMessage).tokens
543
+ if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
544
+ }
545
+ // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
546
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
547
+ dist.apiOutput = num(lastAssMsg?.tokens?.output)
548
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
549
+ } catch {}
550
+ const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
551
+ const skills = [...loadedSkills.values()]
552
+ return { finalDist, finalHasDist, skills }
553
+ })
554
+
555
+ setDataSignal({
556
+ hitRate, read, write, freshInput: input, output, cost, saved, model,
557
+ inputRate, cacheReadRate, cacheWriteRate, hasPricing,
558
+ hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
559
+ trend, hasTrendData, providerName, sessionHitRate,
560
+ dist: distData.finalDist, hasDistData: distData.finalHasDist,
561
+ skills: distData.skills, hasSkills: distData.skills.length > 0,
562
+ })
563
+ })
564
+
565
+ const data = createMemo(() => {
566
+ return dataSignal()
567
+ })
568
+
569
+ // Persist the last valid distribution so that data() can fall back
570
+ // to it while api.state.part() is re-hydrating after a view switch.
571
+ createEffect(() => {
572
+ const d = data()
573
+ if (d.hasDistData) {
574
+ setLastDist({ ...d.dist })
575
+ setLastHasDist(true)
576
+ // Also persist across component remounts (view switches)
577
+ try { props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist }) } catch {}
578
+ }
579
+ })
580
+
581
+ // ── token distribution (in-process via api.state.part) ──
582
+ const [partVersion, setPartVersion] = createSignal(0)
583
+
584
+ // Persist fold state to api.kv
585
+ const KV_PREFIX = "cache_panel"
586
+ const persistFold = (key: string, val: boolean) => {
587
+ try { props.api.kv.set(`${KV_PREFIX}.${key}`, val) } catch {}
588
+ }
589
+
590
+ onMount(() => {
591
+ // Reset panelWidth on (re)mount so the layout uses a clean
592
+ // default until onSizeChange measures the live box dimensions.
593
+ setPanelWidth(DEFAULT_PANEL_WIDTH)
594
+
595
+ // Restore fold state from persisted storage (non-critical — fire and forget)
596
+ try {
597
+ setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)))
598
+ setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
599
+ setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
600
+ setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
601
+ setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
602
+ } catch {}
603
+
604
+ // Restore user config (currency, rate, section visibility).
605
+ // Try synchronously first (kv is usually ready on mount), fall back to
606
+ // polling if the module was reloaded and kv hasn't initialised yet.
607
+ const doRestore = () => {
608
+ try {
609
+ const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
610
+ const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
611
+ if (typeof sym === "string") setCurrencySymbol(sym)
612
+ if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
613
+ setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
614
+ setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
615
+ setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
616
+ setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
617
+ const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
618
+ setBorderVisible(bv !== false)
619
+ // Restore language preference
620
+ const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
621
+ if (savedLang === "zh" || savedLang === "en") {
622
+ setLangZH(savedLang === "zh")
623
+ }
624
+ // Restore distribution snapshot so the token distribution block
625
+ // doesn't blank out while api.state.part() re-hydrates.
626
+ const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
627
+ if (cachedDist) {
628
+ setLastDist(cachedDist)
629
+ setLastHasDist(true)
630
+ }
631
+ } catch {
632
+ // kv read failed signals stay at defaults
633
+ }
634
+ // Re-measure panel width after config signals have settled
635
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
636
+ setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width))
637
+ }
638
+ }
639
+
640
+ if (props.api.kv.ready) {
641
+ doRestore()
642
+ } else {
643
+ // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
644
+ // on platforms where kv initialisation may be delayed (Linux single-thread
645
+ // mode, session switch storms, etc.).
646
+ const MAX_POLL = 100
647
+ let tries = 0
648
+ const pollRestore = () => {
649
+ if (!props.api.kv.ready) {
650
+ if (++tries > MAX_POLL) { doRestore(); return }
651
+ setTimeout(pollRestore, 10)
652
+ return
653
+ }
654
+ doRestore()
655
+ }
656
+ pollRestore()
657
+ }
658
+
659
+ // Debounce partVersion updates so that event bursts during session
660
+ // switching / streaming don't cause data() to re-compute on every
661
+ // single event (up to hundreds per second on Linux single-thread).
662
+ let partTimer: ReturnType<typeof setTimeout> | undefined
663
+ const bumpPartVersion = () => {
664
+ clearTimeout(partTimer)
665
+ partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100)
666
+ }
667
+ const unsubPart = props.api.event.on("message.part.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
668
+ const unsubMsg = props.api.event.on("message.updated", () => { bumpPartVersion(); setRefreshTick(v => v + 1) })
669
+ const unsubSession = props.api.event.on("session.updated", () => { setRefreshTick(v => v + 1) })
670
+ setRefreshTick(v => v + 1)
671
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); unsubSession() })
672
+ })
673
+
674
+ // ── colours ──
675
+ // Pull from the current theme, auto-desaturate if too punchy,
676
+ // fall back to Morandi when a key is missing from the theme.
677
+ const pal = createMemo(() => {
678
+ const t = props.theme as Record<string, unknown>
679
+ const sat = (k: string, fb: string) => desaturateTo(t[k], MAX_SAT, fb)
680
+ return {
681
+ primary: sat("primary", FALLBACK.primary),
682
+ text: sat("text", FALLBACK.text),
683
+ muted: sat("textMuted", FALLBACK.muted),
684
+ success: sat("success", FALLBACK.success),
685
+ warning: sat("warning", FALLBACK.warning),
686
+ error: sat("error", FALLBACK.error),
687
+ border: sat("border", FALLBACK.border),
688
+ }
689
+ })
690
+
691
+ const hitColor = createMemo(() => {
692
+ const r = data().hitRate
693
+ if (r >= 85) return pal().success
694
+ if (r >= 70) return pal().warning
695
+ return pal().error
696
+ })
697
+
698
+ /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
699
+ const gutter = createMemo(() => borderVisible() ? 6 : 0)
700
+
701
+ const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
702
+ function trendLabel(t: number): string {
703
+ return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
704
+ }
705
+
706
+ const barW = createMemo(() => {
707
+ const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
708
+ const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
709
+ return Math.max(3, panelWidth() - overhead)
710
+ })
711
+ const bar = createMemo(() => progressBar(data().hitRate, barW()))
712
+ const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
713
+
714
+ // When border visibility changes the box dimensions shift, which
715
+ // may not reliably trigger onSizeChange across (re)mount cycles.
716
+ // Force panelWidth to resync with the live box after every change.
717
+ createEffect(() => {
718
+ borderVisible()
719
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
720
+ const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
721
+ setPanelWidth((prev) => (prev === w ? prev : w))
722
+ }
723
+ })
724
+
725
+ // left-align label, right-align value — auto-fill space between
726
+ const justify = (label: string, value: string, unit = ""): string => {
727
+ const gauge = panelWidth() - gutter()
728
+ const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
729
+ const gap = Math.max(1, gauge - used)
730
+ return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
731
+ }
732
+
733
+ return (
734
+ <box
735
+ border={borderVisible()}
736
+ {...(borderVisible() ? { borderColor: pal().border } : {})}
737
+ paddingTop={0}
738
+ paddingBottom={0}
739
+ paddingLeft={borderVisible() ? 2 : 0}
740
+ paddingRight={borderVisible() ? 2 : 0}
741
+ flexDirection="column"
742
+ gap={0}
743
+ ref={boxEl}
744
+ onSizeChange={() => {
745
+ // boxEl.width may be undefined before the first measurement — guard with 0
746
+ const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH
747
+ setPanelWidth((prev) => (prev === w ? prev : w))
748
+ }}
749
+ >
750
+ {/* collapsible header */}
751
+ <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
752
+ <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
753
+ <span style={{ fg: pal().primary }}>
754
+ <b>{t().title}</b>
755
+ <Show when={open()}>
756
+ <span style={{ fg: pal().muted }}> (v{PLUGIN_VERSION})</span>
757
+ </Show>
758
+ </span>
759
+ <Show when={!open() && data().hasData}>
760
+ <Show when={data().hasTrendData}>
761
+ <span>
762
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))}
763
+ </span>
764
+ <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
765
+ <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
766
+ {" "}{trendLabel(data().trend)}
767
+ </span>
768
+ </Show>
769
+ <Show when={!data().hasTrendData}>
770
+ <span>
771
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))}
772
+ </span>
773
+ <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
774
+ </Show>
775
+ </Show>
776
+ </text>
777
+
778
+ <Show when={open()}>
779
+ <Show when={data().hasData} fallback={
780
+ <>
781
+ <text fg={pal().muted}>{sep()}</text>
782
+ <text>
783
+ <span style={{ fg: pal().muted }}>{"> "}</span>
784
+ <span style={{ fg: pal().muted }}>{t().noData}</span>
785
+ </text>
786
+ </>
787
+ }>
788
+ <text fg={pal().muted}>{sep()}</text>
789
+
790
+ {/* hit rate + bar — inline to avoid box spacing */}
791
+ <text>
792
+ <span style={{ fg: pal().text }}>{t().hit} </span>
793
+ <span style={{ fg: hitColor() }}>[{bar()}] </span>
794
+ <span style={{ fg: pal().text }}>{pct()}</span>
795
+ <Show when={data().hasTrendData}>
796
+ <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
797
+ {" "}{trendLabel(data().trend)}
798
+ </span>
799
+ </Show>
800
+ </text>
801
+
802
+ {/* session cumulative hit rate */}
803
+ <text fg={pal().muted}>
804
+ {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
805
+ </text>
806
+
807
+ {/* ── detail section (collapsible, default open) ── */}
808
+ <Show when={sectionDetail()}>
809
+ <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
810
+ <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
811
+ <span style={{ fg: pal().primary }}><b>{t().secDetail}</b></span>
812
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))}</span>
813
+ </text>
814
+
815
+ <Show when={detailOpen()}>
816
+ <Show when={data().read > 0}>
817
+ <text fg={pal().muted}>
818
+ {justify(t().read, fmt(data().read), t().tok)}
819
+ </text>
820
+ </Show>
821
+ <Show when={data().write > 0}>
822
+ <text fg={pal().muted}>
823
+ {justify(t().write, fmt(data().write), t().tok)}
824
+ </text>
825
+ </Show>
826
+ <text fg={pal().muted}>
827
+ {justify(t().miss, fmt(data().freshInput), t().tok)}
828
+ </text>
829
+ <text fg={pal().muted}>
830
+ {justify(t().out, fmt(data().output), t().tok)}
831
+ </text>
832
+ <Show when={data().saved > 0}>
833
+ <text>
834
+ <span style={{ fg: pal().muted }}>{t().saved}</span>
835
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
836
+ <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
837
+ </text>
838
+ </Show>
839
+ </Show>
840
+ </Show>
841
+
842
+ {/* ── model section (collapsible, default open) ── */}
843
+ <Show when={sectionModel()}>
844
+ {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
845
+ <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
846
+ <span style={{ fg: pal().primary }}><b>{t().secModel}</b></span>
847
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))}</span>
848
+ </text>}
849
+
850
+ <Show when={modelOpen()}>
851
+ <text fg={pal().text}>
852
+ {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
853
+ </text>
854
+ <Show when={data().providerName}>
855
+ <text fg={pal().muted}>
856
+ {justify(t().provider, data().providerName)}
857
+ </text>
858
+ </Show>
859
+ <text fg={pal().muted}>
860
+ {justify(t().model, data().model)}
861
+ </text>
862
+ <Show when={data().hasPricing}>
863
+ <text fg={pal().muted}>
864
+ {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)}
865
+ </text>
866
+ <Show when={data().cacheReadRate > 0}>
867
+ <text fg={pal().muted}>
868
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)}
869
+ </text>
870
+ </Show>
871
+ <Show when={data().cacheWriteRate > 0}>
872
+ <text fg={pal().muted}>
873
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)}
874
+ </text>
875
+ </Show>
876
+ </Show>
877
+ </Show>
878
+ </Show>
879
+
880
+ {/* ── token distribution (collapsible, default closed) ── */}
881
+ <Show when={sectionDist()}>
882
+ <Show when={data().hasDistData}>
883
+ {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
884
+ <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
885
+ <span style={{ fg: pal().primary }}><b>{t().distTitle}</b></span>
886
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))}</span>
887
+ </text>}
888
+ <Show when={distOpen()}>
889
+ <Show when={data().dist.system > 0}>
890
+ <text fg={pal().muted}>
891
+ {justify(t().distSys, fmt(data().dist.system), t().tok)}
892
+ </text>
893
+ </Show>
894
+ <Show when={data().dist.user > 0}>
895
+ <text fg={pal().muted}>
896
+ {justify(t().distUser, fmt(data().dist.user), t().tok)}
897
+ </text>
898
+ </Show>
899
+ <Show when={data().dist.agent > 0}>
900
+ <text fg={pal().muted}>
901
+ {justify(t().distAgent, fmt(data().dist.agent), t().tok)}
902
+ </text>
903
+ </Show>
904
+ <Show when={data().dist.toolCall > 0}>
905
+ <text fg={pal().muted}>
906
+ {justify(t().distTool, fmt(data().dist.toolCall), t().tok)}
907
+ </text>
908
+ </Show>
909
+ <Show when={data().dist.toolResult > 0}>
910
+ <text fg={pal().muted}>
911
+ {justify(t().distRes, fmt(data().dist.toolResult), t().tok)}
912
+ </text>
913
+ </Show>
914
+ <text fg={pal().text}>
915
+ {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)}
916
+ </text>
917
+ </Show>
918
+ </Show>
919
+ </Show>
920
+
921
+ {/* ── loaded skills (collapsible, default open) ── */}
922
+ <Show when={sectionSkills()}>
923
+ <Show when={data().hasSkills}>
924
+ {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
925
+ <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
926
+ <span style={{ fg: pal().primary }}><b>{t().secSkills}</b></span>
927
+ <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
928
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))}</span>
929
+ </text>}
930
+ <Show when={skillsOpen()}>
931
+ {data().skills.map((sk: { name: string; tokens: number }) => {
932
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok)
933
+ const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
934
+ const label = truncateVisual(sk.name, maxLabel)
935
+ return (
936
+ <text fg={pal().muted}>
937
+ {justify(label, fmt(sk.tokens), t().tok)}
938
+ </text>
939
+ )
940
+ })}
941
+ </Show>
942
+ </Show>
943
+ </Show>
944
+ </Show>
945
+ </Show>
946
+ </box>
947
+ )
948
+ }
949
+
950
+ // ---------------------------------------------------------------------------
951
+ // Plugin entry
952
+ // ---------------------------------------------------------------------------
953
+
954
+ function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
955
+ return {
956
+ order: 55,
957
+ slots: {
958
+ sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
959
+ return (
960
+ <TokenCachePanel
961
+ theme={ctx.theme.current}
962
+ api={api}
963
+ sessionId={input.session_id}
964
+ signals={signals}
965
+ />
966
+ )
967
+ },
968
+ },
969
+ }
970
+ }
971
+
972
+ const tui: TuiPlugin = async (api: TuiPluginApi) => {
973
+ // ── shared panel signals ──────────────────────────────────────
974
+ const [currencySymbol, setCurrencySymbol] = createSignal("$")
975
+ const [exchangeRate, setExchangeRate] = createSignal(1)
976
+ const [sectionDetail, setSectionDetail] = createSignal(true)
977
+ const [sectionModel, setSectionModel] = createSignal(true)
978
+ const [sectionDist, setSectionDist] = createSignal(true)
979
+ const [sectionSkills, setSectionSkills] = createSignal(true)
980
+ const [borderVisible, setBorderVisible] = createSignal(true)
981
+ const [langZH, setLangZH] = createSignal(LANG_ZH)
982
+
983
+ const signals: PanelSignals = {
984
+ currencySymbol, setCurrencySymbol,
985
+ exchangeRate, setExchangeRate,
986
+ langZH, setLangZH,
987
+ sectionDetail, setSectionDetail,
988
+ sectionModel, setSectionModel,
989
+ sectionDist, setSectionDist,
990
+ sectionSkills, setSectionSkills,
991
+ borderVisible, setBorderVisible,
992
+ }
993
+
994
+ api.slots.register(createSidebarSlot(api, signals))
995
+
996
+ // ── slash commands for runtime config ──
997
+ const KV_PREFIX = "cache_panel"
998
+ api.command?.register(() => [
999
+ {
1000
+ title: "Cache: Set Currency",
1001
+ value: "cache.currency",
1002
+ description: "Change the currency unit for cost display",
1003
+ slash: { name: "cache-currency" },
1004
+ onSelect: (dialog) => {
1005
+ dialog?.replace(() => (
1006
+ <api.ui.DialogSelect
1007
+ title="Select Currency"
1008
+ options={Object.entries(CURRENCIES).map(([code, sym]) => ({
1009
+ title: `${code} (${sym})`,
1010
+ value: code,
1011
+ }))}
1012
+ onSelect={(opt) => {
1013
+ const sym = CURRENCIES[opt.value] ?? "$"
1014
+ const defRate = DEFAULT_RATES[opt.value] ?? 1
1015
+ api.kv.set(`${KV_PREFIX}.currency`, sym)
1016
+ api.kv.set(`${KV_PREFIX}.rate`, defRate)
1017
+ signals.setCurrencySymbol(sym)
1018
+ signals.setExchangeRate(defRate)
1019
+ api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
1020
+ dialog?.clear()
1021
+ }}
1022
+ />
1023
+ ))
1024
+ },
1025
+ },
1026
+ {
1027
+ title: "Cache: Set Exchange Rate",
1028
+ value: "cache.rate",
1029
+ description: "Set the exchange rate multiplier for the selected currency",
1030
+ slash: { name: "cache-rate" },
1031
+ onSelect: (dialog) => {
1032
+ dialog?.replace(() => (
1033
+ <api.ui.DialogPrompt
1034
+ title="Exchange Rate"
1035
+ description={() => <text>Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)</text>}
1036
+ placeholder="1.0"
1037
+ value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
1038
+ onConfirm={(val) => {
1039
+ const n = parseFloat(val)
1040
+ if (n > 0) {
1041
+ api.kv.set(`${KV_PREFIX}.rate`, n)
1042
+ signals.setExchangeRate(n)
1043
+ api.ui.toast({ message: `Exchange rate set to ${n}` })
1044
+ }
1045
+ dialog?.clear()
1046
+ }}
1047
+ />
1048
+ ))
1049
+ },
1050
+ },
1051
+ {
1052
+ title: "Cache: Toggle Section",
1053
+ value: "cache.section",
1054
+ description: "Show or hide a sidebar section",
1055
+ slash: { name: "cache-section" },
1056
+ onSelect: (dialog) => {
1057
+ const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1058
+ const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1059
+ const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1060
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1061
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1062
+ dialog?.replace(() => (
1063
+ <api.ui.DialogSelect
1064
+ title="Toggle Section"
1065
+ options={[
1066
+ { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
1067
+ { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
1068
+ { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1069
+ { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
1070
+ { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
1071
+ ]}
1072
+ onSelect={(opt) => {
1073
+ if (opt.value === "border") {
1074
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1075
+ api.kv.set(`${KV_PREFIX}.border`, !cur)
1076
+ signals.setBorderVisible(!cur)
1077
+ api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
1078
+ } else {
1079
+ const key = `${KV_PREFIX}.section.${opt.value}`
1080
+ const cur = Boolean(api.kv.get(key, true))
1081
+ api.kv.set(key, !cur)
1082
+ if (opt.value === "detail") signals.setSectionDetail(!cur)
1083
+ if (opt.value === "model") signals.setSectionModel(!cur)
1084
+ if (opt.value === "dist") signals.setSectionDist(!cur)
1085
+ if (opt.value === "skills") signals.setSectionSkills(!cur)
1086
+ api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1087
+ }
1088
+ dialog?.clear()
1089
+ }}
1090
+ />
1091
+ ))
1092
+ },
1093
+ },
1094
+ {
1095
+ title: "Cache: Show Config",
1096
+ value: "cache.config",
1097
+ description: "Display the current plugin configuration",
1098
+ slash: { name: "cache-config" },
1099
+ onSelect: (dialog) => {
1100
+ const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
1101
+ const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
1102
+ const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1103
+ const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1104
+ const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1105
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1106
+ api.ui.toast({
1107
+ title: "Cache Panel Config",
1108
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
1109
+ duration: 8000,
1110
+ })
1111
+ dialog?.clear()
1112
+ },
1113
+ },
1114
+ {
1115
+ title: "Cache: Switch Language",
1116
+ value: "cache.lang",
1117
+ description: "Switch between Chinese and English display",
1118
+ slash: { name: "cache-lang" },
1119
+ onSelect: (dialog) => {
1120
+ const cur = langZH()
1121
+ dialog?.replace(() => (
1122
+ <api.ui.DialogSelect
1123
+ title="Display Language"
1124
+ options={[
1125
+ { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1126
+ { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1127
+ ]}
1128
+ onSelect={(opt) => {
1129
+ const zh = opt.value === "zh"
1130
+ api.kv.set(`${KV_PREFIX}.lang`, opt.value)
1131
+ setLangZH(zh)
1132
+ api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" })
1133
+ dialog?.clear()
1134
+ }}
1135
+ />
1136
+ ))
1137
+ },
1138
+ },
1139
+ {
1140
+ title: "Cache: Debug Skills Detection",
1141
+ value: "cache.debug-skills",
1142
+ description: "Dump all tool parts found in the current session for skill detection debugging",
1143
+ slash: { name: "cache-debug-skills" },
1144
+ onSelect: () => {
1145
+ const rt = api.route.current
1146
+ if (rt.name !== "session" || !rt.params) {
1147
+ api.ui.toast({ message: "Please run this command inside a session", variant: "warning" })
1148
+ return
1149
+ }
1150
+ const sid = String(rt.params.sessionID)
1151
+ const msgs = api.state.session.messages(sid)
1152
+ const byTool: Record<string, number> = {}
1153
+ const skillParts: string[] = []
1154
+ for (const msg of msgs) {
1155
+ if (msg.role !== "assistant") continue
1156
+ let parts: readonly any[] = []
1157
+ try { parts = api.state.part(msg.id) } catch {}
1158
+ for (const p of parts) {
1159
+ if (p.type === "tool") {
1160
+ const t = String(p.tool ?? "?")
1161
+ byTool[t] = (byTool[t] ?? 0) + 1
1162
+ if (t === "skill") {
1163
+ const meta = p.state?.metadata
1164
+ const rootMeta = p.metadata
1165
+ 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)}"`)
1166
+ }
1167
+ }
1168
+ }
1169
+ }
1170
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
1171
+ 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'"
1172
+ api.ui.toast({
1173
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
1174
+ message: summary + extra,
1175
+ duration: 15000,
1176
+ })
1177
+ },
1178
+ },
1179
+ ])
1180
+ }
1181
+
1182
+ const mod: TuiPluginModule & { id: string } = {
1183
+ id: "opencode-visual-cache",
1184
+ tui,
1185
+ }
1186
+
1187
+ export default mod