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 +71 -15
- package/README_EN.md +70 -20
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/index.js +145 -13
- package/package.json +1 -1
- package/src/_version.ts +1 -1
- package/src/index.tsx +178 -21
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
|
-
- **模型定价**:显示当前模型的输入 /
|
|
43
|
-
-
|
|
42
|
+
- **模型定价**:显示当前模型的输入 / 缓存读 / 缓存写单价(从 provider 配置动态读取)
|
|
43
|
+
- **折叠面板**:主标题默认折叠,点击展开;明细、模型、分布各自独立折叠
|
|
44
44
|
- **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
|
|
45
|
-
- **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool
|
|
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
|
-
|
|
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
|
|
40
|
-
- **Token
|
|
41
|
-
- **Cost & Savings**: Session cost
|
|
42
|
-
- **Model Pricing**:
|
|
43
|
-
- **Collapsible**:
|
|
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)
|
|
46
|
-
- **
|
|
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
|
-
##
|
|
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
|
package/dist/_version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const PLUGIN_VERSION = "1.2.
|
|
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.
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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 +
|
|
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() -
|
|
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:
|
|
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() -
|
|
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
package/src/_version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// auto-generated
|
|
2
|
-
export const PLUGIN_VERSION="1.2.
|
|
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
|
-
|
|
254
|
-
if (
|
|
255
|
-
return
|
|
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
|
-
|
|
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 +
|
|
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() -
|
|
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() -
|
|
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() -
|
|
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() -
|
|
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,
|
|
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("",
|
|
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("",
|
|
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 } = {
|