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