opencode-visual-cache 1.2.4 → 1.2.7

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/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  ---
21
21
 
22
- ## 图片展示
22
+ ## 1. 图片展示
23
23
 
24
24
  <div align="center">
25
25
  <strong>支持折叠,节省侧边栏占用👇</strong> <br>
@@ -34,24 +34,25 @@
34
34
 
35
35
 
36
36
  ---
37
- ## 功能
37
+ ## 2. 功能
38
38
 
39
39
  - **缓存命中率**:实时计算并显示缓存命中率,自适应宽度进度条
40
40
  - **Token 明细**:缓存读 / 缓存写 / 未命中 / 输出,标签左对齐 · 数据右对齐
41
41
  - **费用与节省**:Session 累计费用 + 缓存命中带来的费用节省
42
- - **模型定价**:显示当前模型的输入 / 缓存读单价(从 provider 配置动态读取)
43
- - **折叠面板**:点击标题折叠为一行,节省侧边栏空间
42
+ - **模型定价**:显示当前模型的输入 / 缓存读 / 缓存写单价(从 provider 配置动态读取)
43
+ - **折叠面板**:主标题默认折叠,点击展开;明细、模型、分布各自独立折叠
44
44
  - **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
45
- - **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示可解析 Token 占比
46
- - **区块折叠**:明细、模型、Token 分布各自独立折叠
45
+ - **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示估算 Token 占比
47
46
  - **折叠记忆**:折叠状态持久化,重启后保持
48
47
  - **语言适配**:自动检测系统语言
48
+ - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算
49
+ - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` 动态配置面板
49
50
 
50
51
  ---
51
52
 
52
- ## 安装
53
+ ## 3. 安装
53
54
 
54
- ### 方式一:OpenCode 命令安装(推荐)
55
+ ### 3.1 方式一:OpenCode 命令安装(推荐)
55
56
 
56
57
  在 OpenCode 中按 **`Ctrl + P`** 打开命令面板,搜索 **`install plugin`**,输入:
57
58
 
@@ -61,7 +62,7 @@ opencode-visual-cache@latest
61
62
 
62
63
  回车即可完成安装与配置。
63
64
 
64
- ### 方式二:手动安装
65
+ ### 3.2 方式二:手动安装
65
66
 
66
67
  **1. 安装插件**
67
68
 
@@ -80,13 +81,62 @@ npm install -g opencode-visual-cache@latest
80
81
  }
81
82
  ```
82
83
 
83
- ### 重启 OpenCode
84
+ ### 3.3 重启 OpenCode
84
85
 
85
86
  进入任意 session,侧边栏即可看到缓存统计面板。
86
87
 
87
88
  ---
88
89
 
89
- ## 更新
90
+ ## 4. 使用指南
91
+
92
+ ### 4.1 斜杠命令
93
+
94
+ 插件支持通过斜杠命令或命令面板(`Ctrl + P`)动态调整配置,所有设置即时生效并持久化:
95
+
96
+ | 命令 | 功能 | 使用方式 |
97
+ |------|------|---------|
98
+ | `/cache-currency` | 切换货币单位 | 从列表选择货币(USD / CNY / EUR / JPY / GBP / KRW),自动填入默认汇率 |
99
+ | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 |
100
+ | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 面板边框的显隐 |
101
+ | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 |
102
+
103
+ <div align="center">
104
+ <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="斜杠命令" width="49%"></img>
105
+ <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/ctrlP_cmd.png" alt="Ctrl+P 命令面板" width="49%"></img>
106
+ </div>
107
+
108
+ 切换货币时会自动填入离线内置的近似汇率(以 USD 为基准),用户可随时通过 `/cache-rate` 自定义。
109
+
110
+ ### 4.2 货币与汇率
111
+
112
+ 费用展示支持多币种切换:
113
+
114
+ | 货币代码 | 符号 | 默认汇率(1 USD = ?) |
115
+ |---------|------|---------------------|
116
+ | USD | `$` | 1 |
117
+ | CNY | `¥` | 7.2 |
118
+ | EUR | `€` | 0.92 |
119
+ | JPY | `JP¥` | 150 |
120
+ | GBP | `£` | 0.79 |
121
+ | KRW | `₩` | 1350 |
122
+
123
+ > 汇率会同步应用到 Session 累计费用、缓存节省金额、以及模型单价展示。
124
+ >
125
+ > **基币说明**:插件假设提供商定价均为美元(USD)。目前主流 AI API(OpenAI / Anthropic / Google / DeepSeek / xAI 等)的国际版均以 USD 计价。如果你使用的提供商以人民币或其他货币计价,请将汇率设为 `1`。
126
+
127
+ ### 4.3 区块可见性
128
+
129
+ 面板中的三个子区块可以独立关闭,方便在侧边栏空间紧张时隐藏不需要的信息:
130
+
131
+ - **Token 明细**:缓存读 / 缓存写 / 未命中 / 输出
132
+ - **模型与定价**:费用 / 提供商 / 模型名 / 单价
133
+ - **估算 Token 分布**:按角色拆分的 Token 估算
134
+
135
+ 通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。
136
+
137
+ ---
138
+
139
+ ## 5. 更新
90
140
 
91
141
  由于 [OpenCode 已知问题 #6774](https://github.com/anomalyco/opencode/issues/6774),插件缓存会锁死在首次安装时的版本,不会自动检测 npm 上的新版本。
92
142
 
@@ -112,23 +162,29 @@ rm -rf ~/.cache/opencode/packages/opencode-visual-cache@latest
112
162
 
113
163
  ---
114
164
 
115
- ## 调试
165
+ ## 6. 调试
116
166
 
117
- 强制英文(Windows PowerShell):
167
+ 强制英文:
118
168
 
119
169
  ```powershell
170
+ # Windows PowerShell
120
171
  $env:CACHE_TUI_LANG="en"; opencode
121
172
  ```
122
173
 
174
+ ```bash
175
+ # macOS / Linux
176
+ CACHE_TUI_LANG=en opencode
177
+ ```
178
+
123
179
  ---
124
180
 
125
- ## 兼容性
181
+ ## 7. 兼容性
126
182
 
127
183
  代码完全模型无关,支持所有 OpenCode 兼容的 AI 模型(DeepSeek / Claude / GPT 等)。
128
184
  Token 数据和定价信息均通过 OpenCode SDK 标准接口获取。
129
185
 
130
186
  ---
131
187
 
132
- ## License
188
+ ## 8. License
133
189
 
134
190
  MIT
package/README_EN.md CHANGED
@@ -19,7 +19,7 @@ If you find this plugin useful, a ⭐ would mean a lot — thank you!<br>
19
19
 
20
20
  ---
21
21
 
22
- ## Screenshots
22
+ ## 1. Screenshots
23
23
 
24
24
  <div align="center">
25
25
  <strong>Collapsed 👇</strong> <br>
@@ -34,24 +34,25 @@ If you find this plugin useful, a ⭐ would mean a lot — thank you!<br>
34
34
 
35
35
  ---
36
36
 
37
- ## Features
38
-
39
- - **Cache Hit Rate**: Real-time `cache_read / total_input × 100%` with adaptive-width progress bar
40
- - **Token Breakdown**: Cache Read / Write / Miss / Output, left-aligned labels, right-aligned values
41
- - **Cost & Savings**: Session cost + cache savings (input_rate − cache_read_rate) × cache_read
42
- - **Model Pricing**: Current model's input / cache-read rates (from provider config)
43
- - **Collapsible**: Click title to fold into one line
44
- - **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from theme
45
- - **Token Distribution**: Per-role (system / user / agent instr / tool call / tool result) parseable token breakdown
46
- - **Collapsible Sections**: Detail, model, and token distribution fold independently
47
- - **Persistent Fold State**: Fold preferences remembered across restarts
37
+ ## 2. Features
38
+
39
+ - **Cache Hit Rate**: Real-time hit rate with adaptive-width progress bar and trend indicator
40
+ - **Token Detail**: Cache read / write / miss / output, left-aligned labels, right-aligned values
41
+ - **Cost & Savings**: Session cumulative cost plus cache-hit savings
42
+ - **Model Pricing**: Input / cache-read / cache-write per-million rates (read from provider config dynamically)
43
+ - **Collapsible**: Main title collapsed by default; click to expand. Detail, model, and distribution sections fold independently
44
+ - **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from current theme
45
+ - **Token Distribution**: Per-role (system / user / agent instr / tool call / tool result) estimated token breakdown
46
+ - **Persistent State**: Fold preferences and config remembered across restarts via api.kv
48
47
  - **Language**: Auto-detects system locale
48
+ - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time
49
+ - **Slash Commands**: `/cache-rate` `/cache-section` `/cache-config` for live panel configuration
49
50
 
50
51
  ---
51
52
 
52
- ## Installation
53
+ ## 3. Installation
53
54
 
54
- ### Option 1: OpenCode Command (recommended)
55
+ ### 3.1 Option 1: OpenCode Command (recommended)
55
56
 
56
57
  Press **`Ctrl + P`** in OpenCode to open the command palette, search **`install plugin`**, then type:
57
58
 
@@ -61,7 +62,7 @@ opencode-visual-cache@latest
61
62
 
62
63
  Press Enter to install and configure automatically.
63
64
 
64
- ### Option 2: Manual
65
+ ### 3.2 Option 2: Manual
65
66
 
66
67
  **1. Install the plugin**
67
68
 
@@ -80,13 +81,62 @@ Create or edit `~/.config/opencode/tui.jsonc`:
80
81
  }
81
82
  ```
82
83
 
83
- ### Restart OpenCode
84
+ ### 3.3 Restart OpenCode
84
85
 
85
86
  Open any session — the cache stats panel appears in the sidebar.
86
87
 
87
88
  ---
88
89
 
89
- ## Update
90
+ ## 4. Usage Guide
91
+
92
+ ### 4.1 Slash Commands
93
+
94
+ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime configuration. All changes take effect immediately and are persisted:
95
+
96
+ | Command | Function | How to use |
97
+ |---------|----------|------------|
98
+ | `/cache-currency` | Switch currency | Pick from a list (USD / CNY / EUR / JPY / GBP / KRW); default exchange rate auto-filled |
99
+ | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) |
100
+ | `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, or the panel border |
101
+ | `/cache-config` | View current config | Displays currency, rate, and section visibility |
102
+
103
+ <div align="center">
104
+ <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="Slash command" width="49%"></img>
105
+ <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/ctrlP_cmd.png" alt="Ctrl+P command palette" width="49%"></img>
106
+ </div>
107
+
108
+ Switching currency automatically applies a built-in approximate exchange rate (USD-based). Override it anytime with `/cache-rate`.
109
+
110
+ ### 4.2 Currency & Exchange Rate
111
+
112
+ Cost display supports multiple currencies:
113
+
114
+ | Code | Symbol | Default rate (1 USD = ?) |
115
+ |------|--------|-------------------------|
116
+ | USD | `$` | 1 |
117
+ | CNY | `¥` | 7.2 |
118
+ | EUR | `€` | 0.92 |
119
+ | JPY | `JP¥` | 150 |
120
+ | GBP | `£` | 0.79 |
121
+ | KRW | `₩` | 1350 |
122
+
123
+ > The rate applies to session cost, cache savings, and per-million pricing — consistently across the panel.
124
+ >
125
+ > **Base currency**: The plugin assumes all provider pricing is in USD. Major AI APIs (OpenAI / Anthropic / Google / DeepSeek / xAI etc.) use USD for their international endpoints. If your provider bills in CNY or another currency, set the exchange rate to `1`.
126
+
127
+ ### 4.3 Section Visibility
128
+
129
+ Three sub-sections can be toggled independently to save sidebar space:
130
+
131
+ - **Token Detail**: cache read / write / miss / output
132
+ - **Model & Pricing**: cost / provider / model name / per-million rates
133
+ - **Estimated Token Dist.**: per-role token breakdown
134
+
135
+ Toggled via `/cache-section` — takes effect instantly, no restart required. The same command also toggles the panel **border**; turning it off removes the outline and padding so content fills the full width.
136
+
137
+ ---
138
+
139
+ ## 5. Update
90
140
 
91
141
  Due to a [known OpenCode issue #6774](https://github.com/anomalyco/opencode/issues/6774), the plugin cache locks to the version installed at first setup and does **not** auto-detect newer releases on npm.
92
142
 
@@ -112,7 +162,7 @@ Press **`Ctrl + P`** in OpenCode → `install plugin` → `opencode-visual-cache
112
162
 
113
163
  ---
114
164
 
115
- ## Debug
165
+ ## 6. Debug
116
166
 
117
167
  Force English:
118
168
 
@@ -128,13 +178,13 @@ CACHE_TUI_LANG=en opencode
128
178
 
129
179
  ---
130
180
 
131
- ## Compatibility
181
+ ## 7. Compatibility
132
182
 
133
183
  Model-agnostic — works with all OpenCode-compatible AI models (DeepSeek / Claude / GPT etc.).
134
184
  Token data and pricing are read via OpenCode SDK standard interfaces.
135
185
 
136
186
  ---
137
187
 
138
- ## License
188
+ ## 8. License
139
189
 
140
190
  MIT
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.4";
1
+ export declare const PLUGIN_VERSION = "1.2.7";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.4";
2
+ export const PLUGIN_VERSION = "1.2.7";
package/dist/index.js CHANGED
@@ -222,12 +222,13 @@ function fmt(n) {
222
222
  function num(v) {
223
223
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
224
224
  }
225
- function fmtCost(n) {
226
- if (n >= 1)
227
- return "$" + n.toFixed(2);
228
- if (n >= 0.01)
229
- return "$" + n.toFixed(3);
230
- return "$" + n.toFixed(4);
225
+ function fmtCost(n, symbol = "$", rate = 1) {
226
+ const v = n * rate;
227
+ if (v >= 1)
228
+ return symbol + v.toFixed(2);
229
+ if (v >= 0.01)
230
+ return symbol + v.toFixed(3);
231
+ return symbol + v.toFixed(4);
231
232
  }
232
233
  // ── token estimation ──
233
234
  // Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
@@ -269,10 +270,24 @@ function estimateTokens(text) {
269
270
  // ---------------------------------------------------------------------------
270
271
  // Sidebar component
271
272
  // ---------------------------------------------------------------------------
273
+ /** Module-level config signals — shared between the TUI component and slash
274
+ * commands so that runtime changes take effect immediately without restart. */
275
+ const [currencySymbol, setCurrencySymbol] = createSignal("$");
276
+ const [exchangeRate, setExchangeRate] = createSignal(1);
277
+ const [sectionDetail, setSectionDetail] = createSignal(true);
278
+ const [sectionModel, setSectionModel] = createSignal(true);
279
+ const [sectionDist, setSectionDist] = createSignal(true);
280
+ const [borderVisible, setBorderVisible] = createSignal(true);
281
+ const CURRENCIES = {
282
+ USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
283
+ };
284
+ /** Approximate USD exchange rates — used as defaults when switching currency.
285
+ * Users can override via /cache-rate. Last updated 2026-05. */
286
+ const DEFAULT_RATES = {
287
+ USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
288
+ };
272
289
  const MIN_PANEL_WIDTH = 20;
273
290
  const DEFAULT_PANEL_WIDTH = 26;
274
- /** Horizontal space eaten by border (1+1) + padding (2+2). */
275
- const GUTTER = 2 + 4;
276
291
  /** ── layout measurement constants (visual columns) ── */
277
292
  const LABEL_GAP = 1; // label(如 "Hit")后面的空格
278
293
  const BAR_BRACKETS = 2; // "[" + "]" 包围进度条
@@ -500,6 +515,22 @@ function TokenCachePanel(props) {
500
515
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
501
516
  }
502
517
  catch { }
518
+ // Restore user config (currency, rate, section visibility)
519
+ try {
520
+ const sym = props.api.kv.get(`${KV_PREFIX}.currency`);
521
+ const rate = props.api.kv.get(`${KV_PREFIX}.rate`);
522
+ if (typeof sym === "string")
523
+ setCurrencySymbol(sym);
524
+ if (typeof rate === "number" && rate > 0)
525
+ setExchangeRate(rate);
526
+ setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
527
+ setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
528
+ setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
529
+ const borderVal = props.api.kv.get(`${KV_PREFIX}.border`, true);
530
+ setBorderVisible(borderVal !== false);
531
+ }
532
+ catch { }
533
+ // (border visibility was restored above together with section config)
503
534
  const unsubPart = props.api.event.on("message.part.updated", () => {
504
535
  setPartVersion((v) => v + 1);
505
536
  });
@@ -532,29 +563,31 @@ function TokenCachePanel(props) {
532
563
  return pal().warning;
533
564
  return pal().error;
534
565
  });
535
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - GUTTER)));
566
+ /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
567
+ const gutter = createMemo(() => borderVisible() ? 6 : 0);
568
+ const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())));
536
569
  function trendLabel(t) {
537
570
  return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "");
538
571
  }
539
572
  const barW = createMemo(() => {
540
573
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
541
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER;
574
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
542
575
  return Math.max(3, panelWidth() - overhead);
543
576
  });
544
577
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
545
578
  const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%");
546
579
  // left-align label, right-align value — auto-fill space between
547
580
  const justify = (label, value, unit = "") => {
548
- const gauge = panelWidth() - GUTTER;
581
+ const gauge = panelWidth() - gutter();
549
582
  const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0);
550
583
  const gap = Math.max(1, gauge - used);
551
584
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
552
585
  };
553
- return (_jsxs("box", { border: true, borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: 2, paddingRight: 2, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
586
+ return (_jsxs("box", { border: borderVisible(), borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: borderVisible() ? 2 : 0, paddingRight: borderVisible() ? 2 : 0, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
554
587
  // boxEl.width may be undefined before the first measurement — guard with 0
555
588
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
556
589
  setPanelWidth((prev) => (prev === w ? prev : w));
557
- }, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: T.title }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: pal().muted }, children: [" (v", PLUGIN_VERSION, ")"] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] }), _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] })] }), _jsx(Show, { when: open(), children: _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: T.noData })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [T.hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secDetail }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail)) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.read, fmt(data().read), T.tok) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(data().write), T.tok) }) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(data().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(data().output), T.tok) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: T.saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved)))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved)] })] }) })] }), _jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secModel }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel)) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(data().cost)) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(T.provider, data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate) }) })] })] }), _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.distTitle }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle)) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distSys, fmt(data().dist.system), T.tok) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distUser, fmt(data().dist.user), T.tok) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distAgent, fmt(data().dist.agent), T.tok) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distTool, fmt(data().dist.toolCall), T.tok) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distRes, fmt(data().dist.toolResult), T.tok) }) }), _jsx("text", { fg: pal().text, children: justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok) })] })] })] }) })] }));
590
+ }, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: T.title }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: pal().muted }, children: [" (v", PLUGIN_VERSION, ")"] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] }), _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] })] }), _jsx(Show, { when: open(), children: _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: T.noData })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [T.hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs(Show, { when: sectionDetail(), children: [_jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secDetail }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail)) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.read, fmt(data().read), T.tok) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(data().write), T.tok) }) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(data().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(data().output), T.tok) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: T.saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secModel }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel)) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate())) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(T.provider, data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.distTitle }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle)) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distSys, fmt(data().dist.system), T.tok) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distUser, fmt(data().dist.user), T.tok) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distAgent, fmt(data().dist.agent), T.tok) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distTool, fmt(data().dist.toolCall), T.tok) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distRes, fmt(data().dist.toolResult), T.tok) }) }), _jsx("text", { fg: pal().text, children: justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok) })] })] }) })] }) })] }));
558
591
  }
559
592
  // ---------------------------------------------------------------------------
560
593
  // Plugin entry
@@ -571,6 +604,105 @@ function createSidebarSlot(api) {
571
604
  }
572
605
  const tui = async (api) => {
573
606
  api.slots.register(createSidebarSlot(api));
607
+ // ── slash commands for runtime config ──
608
+ const KV_PREFIX = "cache_panel";
609
+ api.command?.register(() => [
610
+ {
611
+ title: "Cache: Set Currency",
612
+ value: "cache.currency",
613
+ description: "Change the currency unit for cost display",
614
+ slash: { name: "cache-currency" },
615
+ onSelect: (dialog) => {
616
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Select Currency", options: Object.entries(CURRENCIES).map(([code, sym]) => ({
617
+ title: `${code} (${sym})`,
618
+ value: code,
619
+ })), onSelect: (opt) => {
620
+ const sym = CURRENCIES[opt.value] ?? "$";
621
+ const defRate = DEFAULT_RATES[opt.value] ?? 1;
622
+ api.kv.set(`${KV_PREFIX}.currency`, sym);
623
+ api.kv.set(`${KV_PREFIX}.rate`, defRate);
624
+ setCurrencySymbol(sym);
625
+ setExchangeRate(defRate);
626
+ api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` });
627
+ dialog?.clear();
628
+ } })));
629
+ },
630
+ },
631
+ {
632
+ title: "Cache: Set Exchange Rate",
633
+ value: "cache.rate",
634
+ description: "Set the exchange rate multiplier for the selected currency",
635
+ slash: { name: "cache-rate" },
636
+ onSelect: (dialog) => {
637
+ dialog?.replace(() => (_jsx(api.ui.DialogPrompt, { title: "Exchange Rate", description: () => _jsx("text", { children: "Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)" }), placeholder: "1.0", value: String(api.kv.get(`${KV_PREFIX}.rate`, 1)), onConfirm: (val) => {
638
+ const n = parseFloat(val);
639
+ if (n > 0) {
640
+ api.kv.set(`${KV_PREFIX}.rate`, n);
641
+ setExchangeRate(n);
642
+ api.ui.toast({ message: `Exchange rate set to ${n}` });
643
+ }
644
+ dialog?.clear();
645
+ } })));
646
+ },
647
+ },
648
+ {
649
+ title: "Cache: Toggle Section",
650
+ value: "cache.section",
651
+ description: "Show or hide a sidebar section",
652
+ slash: { name: "cache-section" },
653
+ onSelect: (dialog) => {
654
+ const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
655
+ const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
656
+ const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
657
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
658
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
659
+ { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
660
+ { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
661
+ { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
662
+ { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
663
+ ], onSelect: (opt) => {
664
+ if (opt.value === "border") {
665
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
666
+ api.kv.set(`${KV_PREFIX}.border`, !cur);
667
+ setBorderVisible(!cur);
668
+ api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` });
669
+ }
670
+ else {
671
+ const key = `${KV_PREFIX}.section.${opt.value}`;
672
+ const cur = Boolean(api.kv.get(key, true));
673
+ api.kv.set(key, !cur);
674
+ if (opt.value === "detail")
675
+ setSectionDetail(!cur);
676
+ if (opt.value === "model")
677
+ setSectionModel(!cur);
678
+ if (opt.value === "dist")
679
+ setSectionDist(!cur);
680
+ api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
681
+ }
682
+ dialog?.clear();
683
+ } })));
684
+ },
685
+ },
686
+ {
687
+ title: "Cache: Show Config",
688
+ value: "cache.config",
689
+ description: "Display the current plugin configuration",
690
+ slash: { name: "cache-config" },
691
+ onSelect: (dialog) => {
692
+ const sym = api.kv.get(`${KV_PREFIX}.currency`) ?? "$";
693
+ const rate = api.kv.get(`${KV_PREFIX}.rate`) ?? 1;
694
+ const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
695
+ const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
696
+ const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
697
+ api.ui.toast({
698
+ title: "Cache Panel Config",
699
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"}`,
700
+ duration: 8000,
701
+ });
702
+ dialog?.clear();
703
+ },
704
+ },
705
+ ]);
574
706
  };
575
707
  const mod = {
576
708
  id: "opencode-visual-cache",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.2.4",
3
+ "version": "1.2.7",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.2.4";
2
+ export const PLUGIN_VERSION="1.2.7";
package/src/index.tsx CHANGED
@@ -249,10 +249,11 @@ function num(v: unknown): number {
249
249
  return typeof v === "number" && Number.isFinite(v) ? v : 0
250
250
  }
251
251
 
252
- function fmtCost(n: number): string {
253
- if (n >= 1) return "$" + n.toFixed(2)
254
- if (n >= 0.01) return "$" + n.toFixed(3)
255
- return "$" + n.toFixed(4)
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)
256
257
  }
257
258
 
258
259
  // ── token estimation ──
@@ -305,10 +306,26 @@ interface TokenDist {
305
306
  // Sidebar component
306
307
  // ---------------------------------------------------------------------------
307
308
 
309
+ /** Module-level config signals — shared between the TUI component and slash
310
+ * commands so that runtime changes take effect immediately without restart. */
311
+ const [currencySymbol, setCurrencySymbol] = createSignal("$")
312
+ const [exchangeRate, setExchangeRate] = createSignal(1)
313
+ const [sectionDetail, setSectionDetail] = createSignal(true)
314
+ const [sectionModel, setSectionModel] = createSignal(true)
315
+ const [sectionDist, setSectionDist] = createSignal(true)
316
+ const [borderVisible, setBorderVisible] = createSignal(true)
317
+
318
+ const CURRENCIES: Record<string, string> = {
319
+ USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
320
+ }
321
+ /** Approximate USD exchange rates — used as defaults when switching currency.
322
+ * Users can override via /cache-rate. Last updated 2026-05. */
323
+ const DEFAULT_RATES: Record<string, number> = {
324
+ USD: 1, CNY: 7.2, EUR: 0.92, JPY: 150, GBP: 0.79, KRW: 1350,
325
+ }
326
+
308
327
  const MIN_PANEL_WIDTH = 20
309
328
  const DEFAULT_PANEL_WIDTH = 26
310
- /** Horizontal space eaten by border (1+1) + padding (2+2). */
311
- const GUTTER = 2 + 4
312
329
 
313
330
  /** ── layout measurement constants (visual columns) ── */
314
331
  const LABEL_GAP = 1 // label(如 "Hit")后面的空格
@@ -529,6 +546,21 @@ function TokenCachePanel(props: {
529
546
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
530
547
  } catch {}
531
548
 
549
+ // Restore user config (currency, rate, section visibility)
550
+ try {
551
+ const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
552
+ const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
553
+ if (typeof sym === "string") setCurrencySymbol(sym)
554
+ if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
555
+ setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
556
+ setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
557
+ setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
558
+ const borderVal = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
559
+ setBorderVisible(borderVal !== false)
560
+ } catch {}
561
+
562
+ // (border visibility was restored above together with section config)
563
+
532
564
  const unsubPart = props.api.event.on("message.part.updated", () => {
533
565
  setPartVersion((v) => v + 1)
534
566
  })
@@ -562,14 +594,17 @@ function TokenCachePanel(props: {
562
594
  return pal().error
563
595
  })
564
596
 
565
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - GUTTER)))
597
+ /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
598
+ const gutter = createMemo(() => borderVisible() ? 6 : 0)
599
+
600
+ const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
566
601
  function trendLabel(t: number): string {
567
602
  return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
568
603
  }
569
604
 
570
605
  const barW = createMemo(() => {
571
606
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
572
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER
607
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
573
608
  return Math.max(3, panelWidth() - overhead)
574
609
  })
575
610
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
@@ -577,7 +612,7 @@ function TokenCachePanel(props: {
577
612
 
578
613
  // left-align label, right-align value — auto-fill space between
579
614
  const justify = (label: string, value: string, unit = ""): string => {
580
- const gauge = panelWidth() - GUTTER
615
+ const gauge = panelWidth() - gutter()
581
616
  const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
582
617
  const gap = Math.max(1, gauge - used)
583
618
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
@@ -585,12 +620,12 @@ function TokenCachePanel(props: {
585
620
 
586
621
  return (
587
622
  <box
588
- border
623
+ border={borderVisible()}
589
624
  borderColor={pal().border}
590
625
  paddingTop={0}
591
626
  paddingBottom={0}
592
- paddingLeft={2}
593
- paddingRight={2}
627
+ paddingLeft={borderVisible() ? 2 : 0}
628
+ paddingRight={borderVisible() ? 2 : 0}
594
629
  flexDirection="column"
595
630
  gap={0}
596
631
  ref={boxEl}
@@ -612,7 +647,7 @@ function TokenCachePanel(props: {
612
647
  <Show when={!open() && data().hasData}>
613
648
  <Show when={data().hasTrendData}>
614
649
  <span>
615
- {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
650
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
616
651
  </span>
617
652
  <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
618
653
  <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
@@ -621,7 +656,7 @@ function TokenCachePanel(props: {
621
656
  </Show>
622
657
  <Show when={!data().hasTrendData}>
623
658
  <span>
624
- {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
659
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
625
660
  </span>
626
661
  <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
627
662
  </Show>
@@ -658,6 +693,7 @@ function TokenCachePanel(props: {
658
693
  </text>
659
694
 
660
695
  {/* ── detail section (collapsible, default open) ── */}
696
+ <Show when={sectionDetail()}>
661
697
  <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
662
698
  <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
663
699
  <span style={{ fg: pal().primary }}><b>{T.secDetail}</b></span>
@@ -684,13 +720,15 @@ function TokenCachePanel(props: {
684
720
  <Show when={data().saved > 0}>
685
721
  <text>
686
722
  <span style={{ fg: pal().muted }}>{T.saved}</span>
687
- <span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved))))}</span>
688
- <span style={{ fg: pal().success }}>~{fmtCost(data().saved)}</span>
723
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
724
+ <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
689
725
  </text>
690
726
  </Show>
691
727
  </Show>
728
+ </Show>
692
729
 
693
730
  {/* ── model section (collapsible, default open) ── */}
731
+ <Show when={sectionModel()}>
694
732
  {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
695
733
  <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
696
734
  <span style={{ fg: pal().primary }}><b>{T.secModel}</b></span>
@@ -699,7 +737,7 @@ function TokenCachePanel(props: {
699
737
 
700
738
  <Show when={modelOpen()}>
701
739
  <text fg={pal().text}>
702
- {justify(T.cost, fmtCost(data().cost))}
740
+ {justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
703
741
  </text>
704
742
  <Show when={data().providerName}>
705
743
  <text fg={pal().muted}>
@@ -711,22 +749,24 @@ function TokenCachePanel(props: {
711
749
  </text>
712
750
  <Show when={data().hasPricing}>
713
751
  <text fg={pal().muted}>
714
- {justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate)}
752
+ {justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate)}
715
753
  </text>
716
754
  <Show when={data().cacheReadRate > 0}>
717
755
  <text fg={pal().muted}>
718
- {justify("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
756
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate)}
719
757
  </text>
720
758
  </Show>
721
759
  <Show when={data().cacheWriteRate > 0}>
722
760
  <text fg={pal().muted}>
723
- {justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate)}
761
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate)}
724
762
  </text>
725
- </Show>
726
763
  </Show>
727
764
  </Show>
765
+ </Show>
766
+ </Show>
728
767
 
729
768
  {/* ── token distribution (collapsible, default closed) ── */}
769
+ <Show when={sectionDist()}>
730
770
  <Show when={data().hasDistData}>
731
771
  {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
732
772
  <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
@@ -764,6 +804,7 @@ function TokenCachePanel(props: {
764
804
  </text>
765
805
  </Show>
766
806
  </Show>
807
+ </Show>
767
808
  </Show>
768
809
  </Show>
769
810
  </box>
@@ -793,6 +834,122 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
793
834
 
794
835
  const tui: TuiPlugin = async (api: TuiPluginApi) => {
795
836
  api.slots.register(createSidebarSlot(api))
837
+
838
+ // ── slash commands for runtime config ──
839
+ const KV_PREFIX = "cache_panel"
840
+ api.command?.register(() => [
841
+ {
842
+ title: "Cache: Set Currency",
843
+ value: "cache.currency",
844
+ description: "Change the currency unit for cost display",
845
+ slash: { name: "cache-currency" },
846
+ onSelect: (dialog) => {
847
+ dialog?.replace(() => (
848
+ <api.ui.DialogSelect
849
+ title="Select Currency"
850
+ options={Object.entries(CURRENCIES).map(([code, sym]) => ({
851
+ title: `${code} (${sym})`,
852
+ value: code,
853
+ }))}
854
+ onSelect={(opt) => {
855
+ const sym = CURRENCIES[opt.value] ?? "$"
856
+ const defRate = DEFAULT_RATES[opt.value] ?? 1
857
+ api.kv.set(`${KV_PREFIX}.currency`, sym)
858
+ api.kv.set(`${KV_PREFIX}.rate`, defRate)
859
+ setCurrencySymbol(sym)
860
+ setExchangeRate(defRate)
861
+ api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
862
+ dialog?.clear()
863
+ }}
864
+ />
865
+ ))
866
+ },
867
+ },
868
+ {
869
+ title: "Cache: Set Exchange Rate",
870
+ value: "cache.rate",
871
+ description: "Set the exchange rate multiplier for the selected currency",
872
+ slash: { name: "cache-rate" },
873
+ onSelect: (dialog) => {
874
+ dialog?.replace(() => (
875
+ <api.ui.DialogPrompt
876
+ title="Exchange Rate"
877
+ description={() => <text>Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)</text>}
878
+ placeholder="1.0"
879
+ value={String(api.kv.get<number>(`${KV_PREFIX}.rate`, 1))}
880
+ onConfirm={(val) => {
881
+ const n = parseFloat(val)
882
+ if (n > 0) {
883
+ api.kv.set(`${KV_PREFIX}.rate`, n)
884
+ setExchangeRate(n)
885
+ api.ui.toast({ message: `Exchange rate set to ${n}` })
886
+ }
887
+ dialog?.clear()
888
+ }}
889
+ />
890
+ ))
891
+ },
892
+ },
893
+ {
894
+ title: "Cache: Toggle Section",
895
+ value: "cache.section",
896
+ description: "Show or hide a sidebar section",
897
+ slash: { name: "cache-section" },
898
+ onSelect: (dialog) => {
899
+ const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
900
+ const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
901
+ const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
902
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
903
+ dialog?.replace(() => (
904
+ <api.ui.DialogSelect
905
+ title="Toggle Section"
906
+ options={[
907
+ { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
908
+ { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
909
+ { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
910
+ { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
911
+ ]}
912
+ onSelect={(opt) => {
913
+ if (opt.value === "border") {
914
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
915
+ api.kv.set(`${KV_PREFIX}.border`, !cur)
916
+ setBorderVisible(!cur)
917
+ api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
918
+ } else {
919
+ const key = `${KV_PREFIX}.section.${opt.value}`
920
+ const cur = Boolean(api.kv.get(key, true))
921
+ api.kv.set(key, !cur)
922
+ if (opt.value === "detail") setSectionDetail(!cur)
923
+ if (opt.value === "model") setSectionModel(!cur)
924
+ if (opt.value === "dist") setSectionDist(!cur)
925
+ api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
926
+ }
927
+ dialog?.clear()
928
+ }}
929
+ />
930
+ ))
931
+ },
932
+ },
933
+ {
934
+ title: "Cache: Show Config",
935
+ value: "cache.config",
936
+ description: "Display the current plugin configuration",
937
+ slash: { name: "cache-config" },
938
+ onSelect: (dialog) => {
939
+ const sym = api.kv.get<string>(`${KV_PREFIX}.currency`) ?? "$"
940
+ const rate = api.kv.get<number>(`${KV_PREFIX}.rate`) ?? 1
941
+ const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
942
+ const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
943
+ const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
944
+ api.ui.toast({
945
+ title: "Cache Panel Config",
946
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"}`,
947
+ duration: 8000,
948
+ })
949
+ dialog?.clear()
950
+ },
951
+ },
952
+ ])
796
953
  }
797
954
 
798
955
  const mod: TuiPluginModule & { id: string } = {