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/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
- const [lastHasDist, setLastHasDist] = createSignal(false)
385
-
386
- const data = createMemo(() => {
387
- const msgs = props.api.state.session.messages(props.sessionId) as Message[]
388
-
389
- let input = 0
390
- let read = 0
391
- let write = 0
392
- let output = 0
393
- let cost = 0
394
- let pid = ""
395
- let mid = ""
396
-
397
- // Track individual hit rates per assistant message to compute trend
398
- let prevMsgHitRate = -1
399
- let lastMsgHitRate = -1
400
-
401
- for (const msg of msgs) {
402
- if (msg.role !== "assistant") continue
403
- const t = (msg as AssistantMessage).tokens
404
- if (!t) continue
405
- const msgInputTokens = num(t.input) + num(t.cache?.read)
406
- const msgReadTokens = num(t.cache?.read)
407
- if (msgInputTokens > 0) {
408
- prevMsgHitRate = lastMsgHitRate
409
- lastMsgHitRate = (msgReadTokens / msgInputTokens) * 100
410
- }
411
- input += num(t.input)
412
- read += num(t.cache?.read)
413
- write += num(t.cache?.write)
414
- output += num(t.output)
415
- cost += num((msg as AssistantMessage).cost)
416
- if ((msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
417
- pid = (msg as AssistantMessage).providerID
418
- mid = (msg as AssistantMessage).modelID
419
- }
420
- }
421
-
422
- // cost savings from cache hits
423
- let saved = 0
424
- let inputRate = 0
425
- let cacheReadRate = 0
426
- let cacheWriteRate = 0
427
- if (read > 0 && pid && mid) {
428
- for (const provider of props.api.state.provider) {
429
- if (provider.id !== pid) continue
430
- const model = provider.models[mid]
431
- if (!model?.cost) continue
432
- inputRate = num(model.cost.input)
433
- cacheReadRate = num(model.cost.cache?.read)
434
- cacheWriteRate = num(model.cost.cache?.write)
435
- const diff = inputRate - cacheReadRate
436
- if (diff > 0) saved = (read * diff) / 1_000_000
437
- break
438
- }
439
- }
440
-
441
- // `input` from the API represents fresh (non-cached) tokens.
442
- const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
443
- // Total context = fresh + cache.read.
444
- const freshTotal = input + read
445
- const sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
446
- const model = mid.split("/").pop() ?? mid
447
- const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
448
-
449
- let trend = 0
450
- const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
451
- if (hasTrendData) {
452
- trend = lastMsgHitRate - prevMsgHitRate
453
- }
454
-
455
- const providerName = pid || ""
456
-
457
- // ── token distribution (in-process via api.state.part) ──
458
- // Wrapped in try-catch so a part fetching failure never crashes the panel.
459
- let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
460
- let hasDistData = false
461
- try {
462
- partVersion() // track part changes for reactivity
463
-
464
- dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
465
-
466
- // Read agent system prompt once before the message loop. Reading it
467
- // inside the per-user-message branch risks transient unavailability
468
- // (api.state.config not yet resolved during streaming) silently
469
- // resetting a previously-computed value and causing display flicker.
470
- try {
471
- const session = props.api.state.session.get(props.sessionId)
472
- const cfg = props.api.state.config as Record<string, unknown>
473
- const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
474
- const agents = cfg?.agent as Record<string, unknown> | undefined
475
- const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
476
- const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
477
- if (sysPrompt) dist.system = estimateTokens(sysPrompt)
478
- } catch {}
479
-
480
- for (const msg of msgs) {
481
- if (msg.role === "user") {
482
- const um = msg as UserMessage
483
- if (um.system) dist.system += estimateTokens(um.system)
484
- let parts: readonly Part[] = []
485
- try { parts = props.api.state.part(msg.id) } catch {}
486
- for (const p of parts) {
487
- if (p.type === "text" && !(p as unknown as Record<string, unknown>).synthetic && !(p as unknown as Record<string, unknown>).ignored) {
488
- dist.user += estimateTokens((p as unknown as TextPart).text)
489
- } else if (p.type === "file") {
490
- const fp = p as unknown as FilePart
491
- if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value)
492
- }
493
- }
494
- } else if (msg.role === "assistant") {
495
- const am = msg as AssistantMessage
496
- dist.output += num(am.tokens?.output)
497
-
498
- let parts: readonly Part[] = []
499
- try { parts = props.api.state.part(msg.id) } catch {}
500
- for (const p of parts) {
501
- if (p.type === "tool") {
502
- const tp = p as unknown as ToolPart
503
- // Tool call input (params)
504
- let rawInput = ""
505
- try {
506
- rawInput = (tp.state as unknown as { raw?: string }).raw ?? JSON.stringify(tp.state.input)
507
- } catch { try { rawInput = JSON.stringify(tp.state) } catch {} }
508
- if (rawInput) dist.toolCall += estimateTokens(rawInput)
509
- // Tool result output
510
- if (tp.state.status === "completed") {
511
- const completed = tp.state as unknown as { output: string }
512
- if (completed.output) dist.toolResult += estimateTokens(completed.output)
513
- } else if (tp.state.status === "error") {
514
- const errored = tp.state as unknown as { error: string }
515
- if (errored.error) dist.toolResult += estimateTokens(errored.error)
516
- }
517
- } else if (p.type === "reasoning") {
518
- dist.agent += estimateTokens((p as unknown as ReasoningPart).text)
519
- } else if (p.type === "subtask") {
520
- const sub = p as unknown as { prompt: string; description: string }
521
- dist.agent += estimateTokens(sub.prompt || sub.description || "")
522
- } else if (p.type === "step-finish") {
523
- // StepFinishPart carries API-exact per-call token counts.
524
- // Sum across all step-finish parts (one per API call in tool loops).
525
- const sf = p as unknown as { tokens?: { input?: number; output?: number } }
526
- dist.apiInput += sf.tokens?.input ?? 0
527
- dist.apiOutput += sf.tokens?.output ?? 0
528
- }
529
- }
530
- }
531
- }
532
-
533
- const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult
534
- const apiTotalInput = dist.apiInput
535
- // Use API output if available (StepFinishPart is more accurate than AssistantMessage.tokens)
536
- const finalOutput = dist.apiOutput > 0 ? dist.apiOutput : dist.output
537
-
538
- // Gap inference: the SDK does not expose per-part token counts, so any
539
- // API-exact input total that exceeds the locally-estimated sum is attributed
540
- // to system prompt / agent config / tool-definition overhead. Add it to the
541
- // system bucket rather than replacing the local estimate.
542
- const overhead = Math.max(0, apiTotalInput - totalInput)
543
- if (overhead >= 50) {
544
- dist.system += overhead
545
- }
546
-
547
- hasDistData = totalInput > 0 || finalOutput > 0 || apiTotalInput > 0
548
- } catch {
549
- // Graceful degradation — dist stays at zeroes
550
- }
551
-
552
- // Fall back to last known-good distribution while api.state.part()
553
- // is re-hydrating after a view switch.
554
- const finalDist = hasDistData ? dist : lastDist()
555
- const finalHasDist = hasDistData || lastHasDist()
556
-
557
- return {
558
- hitRate, read, write, freshInput: input, output,
559
- cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
560
- hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
561
- trend, hasTrendData,
562
- providerName,
563
- sessionHitRate,
564
- dist: finalDist,
565
- hasDistData: finalHasDist,
566
- }
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
- } catch {}
602
-
603
- // Restore user config (currency, rate, section visibility).
604
- // Try synchronously first (kv is usually ready on mount), fall back to
605
- // polling if the module was reloaded and kv hasn't initialised yet.
606
- const doRestore = () => {
607
- try {
608
- const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
609
- const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
610
- if (typeof sym === "string") setCurrencySymbol(sym)
611
- if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
612
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
613
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
614
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
615
- const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
616
- setBorderVisible(bv !== false)
617
- // Restore distribution snapshot so the token distribution block
618
- // doesn't blank out while api.state.part() re-hydrates.
619
- const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
620
- if (cachedDist) {
621
- setLastDist(cachedDist)
622
- setLastHasDist(true)
623
- }
624
- } catch {
625
- // kv read failed — signals stay at defaults
626
- }
627
- // Re-measure panel width after config signals have settled
628
- if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
629
- setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width))
630
- }
631
- }
632
-
633
- if (props.api.kv.ready) {
634
- doRestore()
635
- } else {
636
- // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
637
- // on platforms where kv initialisation may be delayed (Linux single-thread
638
- // mode, session switch storms, etc.).
639
- const MAX_POLL = 100
640
- let tries = 0
641
- const pollRestore = () => {
642
- if (!props.api.kv.ready) {
643
- if (++tries > MAX_POLL) { doRestore(); return }
644
- setTimeout(pollRestore, 10)
645
- return
646
- }
647
- doRestore()
648
- }
649
- pollRestore()
650
- }
651
-
652
- // Debounce partVersion updates so that event bursts during session
653
- // switching / streaming don't cause data() to re-compute on every
654
- // single event (up to hundreds per second on Linux single-thread).
655
- let partTimer: ReturnType<typeof setTimeout> | undefined
656
- const bumpPartVersion = () => {
657
- clearTimeout(partTimer)
658
- partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100)
659
- }
660
- const unsubPart = props.api.event.on("message.part.updated", bumpPartVersion)
661
- const unsubMsg = props.api.event.on("message.updated", bumpPartVersion)
662
- onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg() })
663
- })
664
-
665
- // ── colours ──
666
- // Pull from the current theme, auto-desaturate if too punchy,
667
- // fall back to Morandi when a key is missing from the theme.
668
- const pal = createMemo(() => {
669
- const t = props.theme as Record<string, unknown>
670
- const sat = (k: string, fb: string) => desaturateTo(t[k], MAX_SAT, fb)
671
- return {
672
- primary: sat("primary", FALLBACK.primary),
673
- text: sat("text", FALLBACK.text),
674
- muted: sat("textMuted", FALLBACK.muted),
675
- success: sat("success", FALLBACK.success),
676
- warning: sat("warning", FALLBACK.warning),
677
- error: sat("error", FALLBACK.error),
678
- border: sat("border", FALLBACK.border),
679
- }
680
- })
681
-
682
- const hitColor = createMemo(() => {
683
- const r = data().hitRate
684
- if (r >= 85) return pal().success
685
- if (r >= 70) return pal().warning
686
- return pal().error
687
- })
688
-
689
- /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
690
- const gutter = createMemo(() => borderVisible() ? 6 : 0)
691
-
692
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
693
- function trendLabel(t: number): string {
694
- return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
695
- }
696
-
697
- const barW = createMemo(() => {
698
- const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
699
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
700
- return Math.max(3, panelWidth() - overhead)
701
- })
702
- const bar = createMemo(() => progressBar(data().hitRate, barW()))
703
- const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
704
-
705
- // When border visibility changes the box dimensions shift, which
706
- // may not reliably trigger onSizeChange across (re)mount cycles.
707
- // Force panelWidth to resync with the live box after every change.
708
- createEffect(() => {
709
- borderVisible()
710
- if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
711
- const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
712
- setPanelWidth((prev) => (prev === w ? prev : w))
713
- }
714
- })
715
-
716
- // left-align label, right-align value — auto-fill space between
717
- const justify = (label: string, value: string, unit = ""): string => {
718
- const gauge = panelWidth() - gutter()
719
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
720
- const gap = Math.max(1, gauge - used)
721
- return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
722
- }
723
-
724
- return (
725
- <box
726
- border={borderVisible()}
727
- {...(borderVisible() ? { borderColor: pal().border } : {})}
728
- paddingTop={0}
729
- paddingBottom={0}
730
- paddingLeft={borderVisible() ? 2 : 0}
731
- paddingRight={borderVisible() ? 2 : 0}
732
- flexDirection="column"
733
- gap={0}
734
- ref={boxEl}
735
- onSizeChange={() => {
736
- // boxEl.width may be undefined before the first measurement — guard with 0
737
- const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH
738
- setPanelWidth((prev) => (prev === w ? prev : w))
739
- }}
740
- >
741
- {/* collapsible header */}
742
- <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
743
- <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
744
- <span style={{ fg: pal().primary }}>
745
- <b>{T.title}</b>
746
- <Show when={open()}>
747
- <span style={{ fg: pal().muted }}> (v{PLUGIN_VERSION})</span>
748
- </Show>
749
- </span>
750
- <Show when={!open() && data().hasData}>
751
- <Show when={data().hasTrendData}>
752
- <span>
753
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
754
- </span>
755
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
756
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
757
- {" "}{trendLabel(data().trend)}
758
- </span>
759
- </Show>
760
- <Show when={!data().hasTrendData}>
761
- <span>
762
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
763
- </span>
764
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
765
- </Show>
766
- </Show>
767
- </text>
768
-
769
- <Show when={open()}>
770
- <Show when={data().hasData} fallback={
771
- <>
772
- <text fg={pal().muted}>{sep()}</text>
773
- <text>
774
- <span style={{ fg: pal().muted }}>{"> "}</span>
775
- <span style={{ fg: pal().muted }}>{T.noData}</span>
776
- </text>
777
- </>
778
- }>
779
- <text fg={pal().muted}>{sep()}</text>
780
-
781
- {/* hit rate + bar — inline to avoid box spacing */}
782
- <text>
783
- <span style={{ fg: pal().text }}>{T.hit} </span>
784
- <span style={{ fg: hitColor() }}>[{bar()}] </span>
785
- <span style={{ fg: pal().text }}>{pct()}</span>
786
- <Show when={data().hasTrendData}>
787
- <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
788
- {" "}{trendLabel(data().trend)}
789
- </span>
790
- </Show>
791
- </text>
792
-
793
- {/* session cumulative hit rate */}
794
- <text fg={pal().muted}>
795
- {justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
796
- </text>
797
-
798
- {/* ── detail section (collapsible, default open) ── */}
799
- <Show when={sectionDetail()}>
800
- <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
801
- <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
802
- <span style={{ fg: pal().primary }}><b>{T.secDetail}</b></span>
803
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail))}</span>
804
- </text>
805
-
806
- <Show when={detailOpen()}>
807
- <Show when={data().read > 0}>
808
- <text fg={pal().muted}>
809
- {justify(T.read, fmt(data().read), T.tok)}
810
- </text>
811
- </Show>
812
- <Show when={data().write > 0}>
813
- <text fg={pal().muted}>
814
- {justify(T.write, fmt(data().write), T.tok)}
815
- </text>
816
- </Show>
817
- <text fg={pal().muted}>
818
- {justify(T.miss, fmt(data().freshInput), T.tok)}
819
- </text>
820
- <text fg={pal().muted}>
821
- {justify(T.out, fmt(data().output), T.tok)}
822
- </text>
823
- <Show when={data().saved > 0}>
824
- <text>
825
- <span style={{ fg: pal().muted }}>{T.saved}</span>
826
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
827
- <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
828
- </text>
829
- </Show>
830
- </Show>
831
- </Show>
832
-
833
- {/* ── model section (collapsible, default open) ── */}
834
- <Show when={sectionModel()}>
835
- {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
836
- <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
837
- <span style={{ fg: pal().primary }}><b>{T.secModel}</b></span>
838
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel))}</span>
839
- </text>}
840
-
841
- <Show when={modelOpen()}>
842
- <text fg={pal().text}>
843
- {justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
844
- </text>
845
- <Show when={data().providerName}>
846
- <text fg={pal().muted}>
847
- {justify(T.provider, data().providerName)}
848
- </text>
849
- </Show>
850
- <text fg={pal().muted}>
851
- {justify(T.model, data().model)}
852
- </text>
853
- <Show when={data().hasPricing}>
854
- <text fg={pal().muted}>
855
- {justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate)}
856
- </text>
857
- <Show when={data().cacheReadRate > 0}>
858
- <text fg={pal().muted}>
859
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate)}
860
- </text>
861
- </Show>
862
- <Show when={data().cacheWriteRate > 0}>
863
- <text fg={pal().muted}>
864
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate)}
865
- </text>
866
- </Show>
867
- </Show>
868
- </Show>
869
- </Show>
870
-
871
- {/* ── token distribution (collapsible, default closed) ── */}
872
- <Show when={sectionDist()}>
873
- <Show when={data().hasDistData}>
874
- {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
875
- <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
876
- <span style={{ fg: pal().primary }}><b>{T.distTitle}</b></span>
877
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle))}</span>
878
- </text>}
879
- <Show when={distOpen()}>
880
- <Show when={data().dist.system > 0}>
881
- <text fg={pal().muted}>
882
- {justify(T.distSys, fmt(data().dist.system), T.tok)}
883
- </text>
884
- </Show>
885
- <Show when={data().dist.user > 0}>
886
- <text fg={pal().muted}>
887
- {justify(T.distUser, fmt(data().dist.user), T.tok)}
888
- </text>
889
- </Show>
890
- <Show when={data().dist.agent > 0}>
891
- <text fg={pal().muted}>
892
- {justify(T.distAgent, fmt(data().dist.agent), T.tok)}
893
- </text>
894
- </Show>
895
- <Show when={data().dist.toolCall > 0}>
896
- <text fg={pal().muted}>
897
- {justify(T.distTool, fmt(data().dist.toolCall), T.tok)}
898
- </text>
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