opencode-visual-cache 1.0.0
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/LICENSE +21 -0
- package/README.md +72 -0
- package/README_EN.md +78 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +313 -0
- package/install.mjs +114 -0
- package/package.json +54 -0
- package/src/index.tsx +439 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hotakus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<strong>
|
|
3
|
+
<h1>OpenCode Visual Cache</h1>
|
|
4
|
+
实时 Token 缓存命中率 · TUI 侧边栏可视化<br>
|
|
5
|
+
自适应主题色 · 自动低饱和设计语言 · 支持中/英双语
|
|
6
|
+
</strong>
|
|
7
|
+
<br>
|
|
8
|
+
|
|
9
|
+
[](https://github.com/Hotakus/opencode-visual-cache/stargazers)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](README_EN.md)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 图片展示
|
|
18
|
+
|
|
19
|
+
<div align="center">
|
|
20
|
+
<strong>支持折叠,节省侧边栏占用👇</strong> <br>
|
|
21
|
+
<img src="./assets/example2.png"></img>
|
|
22
|
+
</div>
|
|
23
|
+
<div align="center">
|
|
24
|
+
<strong>展开👇</strong> <br>
|
|
25
|
+
<img src="./assets/example1.png"></img>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
## 功能
|
|
30
|
+
|
|
31
|
+
- **缓存命中率**:实时计算并显示缓存命中率,自适应宽度进度条
|
|
32
|
+
- **Token 明细**:缓存读 / 缓存写 / 未命中 / 输出,标签左对齐 · 数据右对齐
|
|
33
|
+
- **费用与节省**:Session 累计费用 + 缓存命中带来的费用节省
|
|
34
|
+
- **模型定价**:显示当前模型的输入 / 缓存读单价(从 provider 配置动态读取)
|
|
35
|
+
- **折叠面板**:点击标题折叠为一行,节省侧边栏空间
|
|
36
|
+
- **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
|
|
37
|
+
- **语言适配**:自动检测系统语言
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
> [!NOTE]
|
|
44
|
+
> 安装方式待定 —— 本喵还在研究如何发布喵~
|
|
45
|
+
> 发布后将更新此部分。
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
// TODO: 安装步骤
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 调试
|
|
54
|
+
|
|
55
|
+
强制英文(Windows PowerShell):
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
$env:CACHE_TUI_LANG="en"; opencode
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 兼容性
|
|
64
|
+
|
|
65
|
+
代码完全模型无关,支持所有 OpenCode 兼容的 AI 模型(DeepSeek / Claude / GPT 等)。
|
|
66
|
+
Token 数据和定价信息均通过 OpenCode SDK 标准接口获取。
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/README_EN.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<strong>
|
|
3
|
+
<h1>OpenCode Visual Cache</h1>
|
|
4
|
+
Real-time Token Cache Hit Rate · TUI Sidebar Visualization<br>
|
|
5
|
+
Adaptive Theme Colors · Auto-desaturated · Chinese / English
|
|
6
|
+
</strong>
|
|
7
|
+
<br>
|
|
8
|
+
|
|
9
|
+
[](https://github.com/Hotakus/opencode-visual-cache/stargazers)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](README.md)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Screenshots
|
|
18
|
+
|
|
19
|
+
<div align="center">
|
|
20
|
+
<strong>Collapsed 👇</strong> <br>
|
|
21
|
+
<img src="./assets/example2.png"></img>
|
|
22
|
+
</div>
|
|
23
|
+
<div align="center">
|
|
24
|
+
<strong>Expanded 👇</strong> <br>
|
|
25
|
+
<img src="./assets/example1.png"></img>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Cache Hit Rate**: Real-time `cache_read / total_input × 100%` with adaptive-width progress bar
|
|
33
|
+
- **Token Breakdown**: Cache Read / Write / Miss / Output, left-aligned labels, right-aligned values
|
|
34
|
+
- **Cost & Savings**: Session cost + cache savings (input_rate − cache_read_rate) × cache_read
|
|
35
|
+
- **Model Pricing**: Current model's input / cache-read rates (from provider config)
|
|
36
|
+
- **Collapsible**: Click title to fold into one line
|
|
37
|
+
- **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from theme
|
|
38
|
+
- **Language**: Auto-detects system locale, switches between Chinese and English
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
> [!NOTE]
|
|
45
|
+
> Installation method TBD — will be updated after publishing.
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
// TODO: install steps
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Debug
|
|
54
|
+
|
|
55
|
+
Force English:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
# Windows PowerShell
|
|
59
|
+
$env:CACHE_TUI_LANG="en"; opencode
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# macOS / Linux
|
|
64
|
+
CACHE_TUI_LANG=en opencode
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Compatibility
|
|
70
|
+
|
|
71
|
+
Model-agnostic — works with all OpenCode-compatible AI models (DeepSeek / Claude / GPT etc.).
|
|
72
|
+
Token data and pricing are read via OpenCode SDK standard interfaces.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
|
|
2
|
+
import { createMemo, createSignal, Show } from "solid-js";
|
|
3
|
+
// ── terminal-width helpers ────────────────────────────────────────
|
|
4
|
+
// CJK characters occupy 2 terminal columns; padEnd/padStart count
|
|
5
|
+
// string length (=1 per char), which breaks alignment with mixed text.
|
|
6
|
+
function charColumns(c) {
|
|
7
|
+
const code = c.codePointAt(0) ?? 0;
|
|
8
|
+
if (code < 0x20)
|
|
9
|
+
return 0; // control
|
|
10
|
+
if (code < 0x7F)
|
|
11
|
+
return 1; // ASCII
|
|
12
|
+
if (code < 0xA0)
|
|
13
|
+
return 0; // C1 controls
|
|
14
|
+
// East-Asian wide / fullwidth ranges
|
|
15
|
+
if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
16
|
+
(code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
|
|
17
|
+
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul
|
|
18
|
+
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
|
|
19
|
+
(code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
|
|
20
|
+
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
|
|
21
|
+
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
22
|
+
(code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
|
|
23
|
+
(code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
|
|
24
|
+
return 2;
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
function visualWidth(s) {
|
|
28
|
+
let w = 0;
|
|
29
|
+
for (const c of s)
|
|
30
|
+
w += charColumns(c);
|
|
31
|
+
return w;
|
|
32
|
+
}
|
|
33
|
+
function visualPadEnd(s, cols) {
|
|
34
|
+
const pad = cols - visualWidth(s);
|
|
35
|
+
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
36
|
+
}
|
|
37
|
+
// ── debug: set CACHE_TUI_LANG=en|zh to override auto-detection ──
|
|
38
|
+
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined;
|
|
39
|
+
// ── language ──────────────────────────────────────────────────────
|
|
40
|
+
const LANG_ZH = DEBUG_LANG
|
|
41
|
+
? DEBUG_LANG === "zh"
|
|
42
|
+
: (() => {
|
|
43
|
+
try {
|
|
44
|
+
return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
const T = LANG_ZH ? {
|
|
51
|
+
title: "缓存统计",
|
|
52
|
+
hit: "命中率",
|
|
53
|
+
read: "缓存读:",
|
|
54
|
+
write: "缓存写:",
|
|
55
|
+
miss: "未命中:",
|
|
56
|
+
out: "输出:",
|
|
57
|
+
cost: "费用:",
|
|
58
|
+
saved: "累计节省:",
|
|
59
|
+
model: "模型:",
|
|
60
|
+
rate: "单价:",
|
|
61
|
+
hitFolded: "命中",
|
|
62
|
+
inputRate: "输入",
|
|
63
|
+
cacheRate: "缓存",
|
|
64
|
+
tok: "tok",
|
|
65
|
+
} : {
|
|
66
|
+
title: "Token Cache",
|
|
67
|
+
hit: "Hit",
|
|
68
|
+
read: "Read:",
|
|
69
|
+
write: "Write:",
|
|
70
|
+
miss: "Miss:",
|
|
71
|
+
out: "Out:",
|
|
72
|
+
cost: "Cost:",
|
|
73
|
+
saved: "Total Saved:",
|
|
74
|
+
model: "Model:",
|
|
75
|
+
rate: "Rate:",
|
|
76
|
+
hitFolded: "hit",
|
|
77
|
+
inputRate: "in",
|
|
78
|
+
cacheRate: "cache",
|
|
79
|
+
tok: "tok",
|
|
80
|
+
};
|
|
81
|
+
// ── color helpers ────────────────────────────────────────────────
|
|
82
|
+
/** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
|
|
83
|
+
function rgb(raw) {
|
|
84
|
+
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
85
|
+
const h = raw.slice(1);
|
|
86
|
+
return {
|
|
87
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
88
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
89
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (raw && typeof raw === "object") {
|
|
93
|
+
const o = raw;
|
|
94
|
+
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
95
|
+
// RGBA channels may be 0-1 floats; detect and upscale.
|
|
96
|
+
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255;
|
|
97
|
+
return {
|
|
98
|
+
r: Math.round(o.r * scale),
|
|
99
|
+
g: Math.round(o.g * scale),
|
|
100
|
+
b: Math.round(o.b * scale),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/** HSL saturation of an RGB color (0–1). */
|
|
107
|
+
function saturation(r, g, b) {
|
|
108
|
+
const max = Math.max(r, g, b) / 255;
|
|
109
|
+
const min = Math.min(r, g, b) / 255;
|
|
110
|
+
const delta = max - min;
|
|
111
|
+
if (delta === 0)
|
|
112
|
+
return 0;
|
|
113
|
+
const L = (max + min) / 2;
|
|
114
|
+
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* If the colour's saturation exceeds `maxSat`, pull it toward grey
|
|
118
|
+
* until saturation drops to maxSat. Returns a hex string.
|
|
119
|
+
*/
|
|
120
|
+
function desaturateTo(raw, maxSat, fallback) {
|
|
121
|
+
const c = rgb(raw);
|
|
122
|
+
if (!c)
|
|
123
|
+
return fallback;
|
|
124
|
+
const sat = saturation(c.r, c.g, c.b);
|
|
125
|
+
if (sat <= maxSat) {
|
|
126
|
+
// already muted — return as hex
|
|
127
|
+
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
128
|
+
}
|
|
129
|
+
// binary search the right amount of grey to mix in
|
|
130
|
+
let lo = 0, hi = 1;
|
|
131
|
+
for (let i = 0; i < 12; i++) {
|
|
132
|
+
const mid = (lo + hi) / 2;
|
|
133
|
+
const nr = Math.round(c.r + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.r) * mid);
|
|
134
|
+
const ng = Math.round(c.g + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.g) * mid);
|
|
135
|
+
const nb = Math.round(c.b + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.b) * mid);
|
|
136
|
+
if (saturation(nr, ng, nb) > maxSat)
|
|
137
|
+
lo = mid;
|
|
138
|
+
else
|
|
139
|
+
hi = mid;
|
|
140
|
+
}
|
|
141
|
+
const grey = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
|
|
142
|
+
const nr = Math.round(c.r + (grey - c.r) * hi);
|
|
143
|
+
const ng = Math.round(c.g + (grey - c.g) * hi);
|
|
144
|
+
const nb = Math.round(c.b + (grey - c.b) * hi);
|
|
145
|
+
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
|
146
|
+
}
|
|
147
|
+
// Morandi fallbacks — used when a theme colour cannot be resolved
|
|
148
|
+
const FALLBACK = {
|
|
149
|
+
primary: "#8B9DAF",
|
|
150
|
+
text: "#C5C5BB",
|
|
151
|
+
muted: "#7A7A72",
|
|
152
|
+
success: "#9CAF8B",
|
|
153
|
+
warning: "#C5B88D",
|
|
154
|
+
error: "#B08A8A",
|
|
155
|
+
border: "#6B6B63",
|
|
156
|
+
};
|
|
157
|
+
/** Morandi ceiling — colours above this saturation get pulled down. */
|
|
158
|
+
const MAX_SAT = 0.28;
|
|
159
|
+
function progressBar(percent, width) {
|
|
160
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
161
|
+
const filled = Math.round((clamped / 100) * width);
|
|
162
|
+
const empty = Math.max(0, width - filled);
|
|
163
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
164
|
+
}
|
|
165
|
+
function fmt(n) {
|
|
166
|
+
if (n >= 1_000_000)
|
|
167
|
+
return (n / 1_000_000).toFixed(1) + "M";
|
|
168
|
+
if (n >= 10_000)
|
|
169
|
+
return Math.round(n / 1_000) + "K";
|
|
170
|
+
return n.toLocaleString("en-US");
|
|
171
|
+
}
|
|
172
|
+
function num(v) {
|
|
173
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
174
|
+
}
|
|
175
|
+
function fmtCost(n) {
|
|
176
|
+
if (n >= 1)
|
|
177
|
+
return "$" + n.toFixed(2);
|
|
178
|
+
if (n >= 0.01)
|
|
179
|
+
return "$" + n.toFixed(3);
|
|
180
|
+
return "$" + n.toFixed(4);
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Sidebar component
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
const MIN_PANEL_WIDTH = 20;
|
|
186
|
+
const DEFAULT_PANEL_WIDTH = 26;
|
|
187
|
+
/** Horizontal space eaten by border (1+1) + padding (2+2). */
|
|
188
|
+
const GUTTER = 2 + 4;
|
|
189
|
+
function TokenCachePanel(props) {
|
|
190
|
+
const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH);
|
|
191
|
+
const [open, setOpen] = createSignal(true);
|
|
192
|
+
// ── scan session messages reactively ──
|
|
193
|
+
// SolidJS createMemo re-evaluates whenever the underlying
|
|
194
|
+
// api.state.session state changes — no event listener needed.
|
|
195
|
+
const data = createMemo(() => {
|
|
196
|
+
const msgs = props.api.state.session.messages(props.sessionId);
|
|
197
|
+
let input = 0;
|
|
198
|
+
let read = 0;
|
|
199
|
+
let write = 0;
|
|
200
|
+
let output = 0;
|
|
201
|
+
let cost = 0;
|
|
202
|
+
let pid = "";
|
|
203
|
+
let mid = "";
|
|
204
|
+
for (const msg of msgs) {
|
|
205
|
+
if (msg.role !== "assistant")
|
|
206
|
+
continue;
|
|
207
|
+
const t = msg.tokens;
|
|
208
|
+
input += num(t.input);
|
|
209
|
+
read += num(t.cache?.read);
|
|
210
|
+
write += num(t.cache?.write);
|
|
211
|
+
output += num(t.output);
|
|
212
|
+
cost += num(msg.cost);
|
|
213
|
+
if (msg.providerID && msg.modelID) {
|
|
214
|
+
pid = msg.providerID;
|
|
215
|
+
mid = msg.modelID;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// cost savings from cache hits
|
|
219
|
+
let saved = 0;
|
|
220
|
+
let inputRate = 0;
|
|
221
|
+
let cacheReadRate = 0;
|
|
222
|
+
if (read > 0 && pid && mid) {
|
|
223
|
+
for (const provider of props.api.state.provider) {
|
|
224
|
+
if (provider.id !== pid)
|
|
225
|
+
continue;
|
|
226
|
+
const model = provider.models[mid];
|
|
227
|
+
if (!model?.cost)
|
|
228
|
+
continue;
|
|
229
|
+
inputRate = num(model.cost.input);
|
|
230
|
+
cacheReadRate = num(model.cost.cache?.read);
|
|
231
|
+
const diff = inputRate - cacheReadRate;
|
|
232
|
+
if (diff > 0)
|
|
233
|
+
saved = (read * diff) / 1_000_000;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// `input` from the API represents fresh (non-cached) tokens.
|
|
238
|
+
// Total context = fresh + cache.read.
|
|
239
|
+
const totalInput = input + read;
|
|
240
|
+
const hitRate = totalInput > 0 ? (read / totalInput) * 100 : 0;
|
|
241
|
+
const model = mid.split("/").pop() ?? mid;
|
|
242
|
+
const hasPricing = inputRate > 0 || cacheReadRate > 0;
|
|
243
|
+
return {
|
|
244
|
+
hitRate, read, write, freshInput: input, output,
|
|
245
|
+
cost, saved, model, inputRate, cacheReadRate, hasPricing,
|
|
246
|
+
hasData: read > 0 || write > 0 || cost > 0,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
const d = () => data();
|
|
250
|
+
// ── colours ──
|
|
251
|
+
// Pull from the current theme, auto-desaturate if too punchy,
|
|
252
|
+
// fall back to Morandi when a key is missing from the theme.
|
|
253
|
+
const pal = createMemo(() => {
|
|
254
|
+
const t = props.theme;
|
|
255
|
+
const sat = (k, fb) => desaturateTo(t[k], MAX_SAT, fb);
|
|
256
|
+
return {
|
|
257
|
+
primary: sat("primary", FALLBACK.primary),
|
|
258
|
+
text: sat("text", FALLBACK.text),
|
|
259
|
+
muted: sat("textMuted", FALLBACK.muted),
|
|
260
|
+
success: sat("success", FALLBACK.success),
|
|
261
|
+
warning: sat("warning", FALLBACK.warning),
|
|
262
|
+
error: sat("error", FALLBACK.error),
|
|
263
|
+
border: sat("border", FALLBACK.border),
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
const hitColor = createMemo(() => {
|
|
267
|
+
const r = d().hitRate;
|
|
268
|
+
if (r >= 85)
|
|
269
|
+
return pal().success;
|
|
270
|
+
if (r >= 70)
|
|
271
|
+
return pal().warning;
|
|
272
|
+
return pal().error;
|
|
273
|
+
});
|
|
274
|
+
const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - GUTTER)));
|
|
275
|
+
const barW = createMemo(() => {
|
|
276
|
+
const overhead = visualWidth(T.hit) + 1 + 2 + 1 + /*pct*/ 5 + GUTTER;
|
|
277
|
+
return Math.max(3, panelWidth() - overhead);
|
|
278
|
+
});
|
|
279
|
+
const bar = createMemo(() => progressBar(d().hitRate, barW()));
|
|
280
|
+
const pct = createMemo(() => d().hitRate.toFixed(1) + "%");
|
|
281
|
+
// left-align label, right-align value — auto-fill space between
|
|
282
|
+
const justify = (label, value, unit = "") => {
|
|
283
|
+
const gauge = panelWidth() - GUTTER;
|
|
284
|
+
const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + 1 : 0);
|
|
285
|
+
const gap = Math.max(1, gauge - used);
|
|
286
|
+
return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
|
|
287
|
+
};
|
|
288
|
+
return (_jsx(Show, { when: d().hasData, children: _jsxs("box", { border: true, borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: 2, paddingRight: 2, flexDirection: "column", gap: 0, onSizeChange: function () {
|
|
289
|
+
const w = Math.max(MIN_PANEL_WIDTH, this.width);
|
|
290
|
+
setPanelWidth((prev) => (prev === w ? prev : w));
|
|
291
|
+
}, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => !o), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.title }) }), _jsxs(Show, { when: !open(), children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - 2 - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] }), _jsxs(Show, { when: open(), 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("text", { fg: pal().muted, children: justify(T.read, fmt(d().read), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(d().write), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(d().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(d().output), T.tok) }), _jsx("text", { fg: pal().muted, children: sep() }), _jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(d().cost)) }), _jsx(Show, { when: d().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(d().saved)))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(d().saved)] })] }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, d().model) }), _jsxs(Show, { when: d().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, "$" + d().inputRate.toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: d().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", "$" + d().cacheReadRate.toFixed(2) + "/M " + T.cacheRate) }) })] })] })] }) }));
|
|
292
|
+
}
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Plugin entry
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
function createSidebarSlot(api) {
|
|
297
|
+
return {
|
|
298
|
+
order: 55,
|
|
299
|
+
slots: {
|
|
300
|
+
sidebar_content(ctx, input) {
|
|
301
|
+
return (_jsx(TokenCachePanel, { theme: ctx.theme.current, api: api, sessionId: input.session_id }));
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const tui = async (api) => {
|
|
307
|
+
api.slots.register(createSidebarSlot(api));
|
|
308
|
+
};
|
|
309
|
+
const mod = {
|
|
310
|
+
id: "opencode-visual-cache",
|
|
311
|
+
tui,
|
|
312
|
+
};
|
|
313
|
+
export default mod;
|
package/install.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Install script for opencode-visual-cache.
|
|
5
|
+
*
|
|
6
|
+
* Creates or updates `~/.config/opencode/tui.jsonc` so OpenCode loads
|
|
7
|
+
* the TUI sidebar plugin. Also optionally adds the plugin to
|
|
8
|
+
* `opencode.jsonc` for forward compatibility.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node install.mjs
|
|
12
|
+
* npm explore opencode-cache-hit-tui -- node install.mjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, mkdir, access } from "node:fs/promises"
|
|
16
|
+
import { constants } from "node:fs"
|
|
17
|
+
import { homedir, platform } from "node:os"
|
|
18
|
+
import { join, dirname } from "node:path"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const PLUGIN_SPEC = "opencode-visual-cache"
|
|
25
|
+
|
|
26
|
+
function configDir() {
|
|
27
|
+
if (platform() === "win32") {
|
|
28
|
+
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "opencode")
|
|
29
|
+
}
|
|
30
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function exists(p) {
|
|
34
|
+
try { await access(p, constants.F_OK); return true }
|
|
35
|
+
catch { return false }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readJSONC(p) {
|
|
39
|
+
const raw = await readFile(p, "utf-8")
|
|
40
|
+
// Strip single-line comments (//) outside strings — simple heuristic.
|
|
41
|
+
const stripped = raw.replace(/^\s*\/\/.*$/gm, "")
|
|
42
|
+
return JSON.parse(stripped)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatJSONC(obj) {
|
|
46
|
+
return JSON.stringify(obj, null, 2) + "\n"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Merge plugin into an existing plugin array, avoiding duplicates. */
|
|
50
|
+
function mergePlugin(existing, spec) {
|
|
51
|
+
const plugins = existing.plugin ?? []
|
|
52
|
+
if (plugins.some((p) => (typeof p === "string" ? p : p[0]) === spec)) {
|
|
53
|
+
return false // already present
|
|
54
|
+
}
|
|
55
|
+
existing.plugin = [...plugins, spec]
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Main
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const dir = configDir()
|
|
65
|
+
await mkdir(dir, { recursive: true })
|
|
66
|
+
|
|
67
|
+
// ---- tui.jsonc ----
|
|
68
|
+
const tuiPath = join(dir, "tui.jsonc")
|
|
69
|
+
let tuiChanged = false
|
|
70
|
+
|
|
71
|
+
if (await exists(tuiPath)) {
|
|
72
|
+
const cfg = await readJSONC(tuiPath)
|
|
73
|
+
tuiChanged = mergePlugin(cfg, PLUGIN_SPEC)
|
|
74
|
+
if (tuiChanged) {
|
|
75
|
+
await writeFile(tuiPath, formatJSONC(cfg))
|
|
76
|
+
console.log(`[opencode-cache-hit-tui] Added to ${tuiPath}`)
|
|
77
|
+
} else {
|
|
78
|
+
console.log(`[opencode-cache-hit-tui] Already in ${tuiPath}`)
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
const cfg = {
|
|
82
|
+
$schema: "https://opencode.ai/tui.json",
|
|
83
|
+
plugin: [PLUGIN_SPEC],
|
|
84
|
+
}
|
|
85
|
+
await writeFile(tuiPath, formatJSONC(cfg))
|
|
86
|
+
console.log(`[opencode-cache-hit-tui] Created ${tuiPath}`)
|
|
87
|
+
tuiChanged = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- opencode.jsonc (forward compat) ----
|
|
91
|
+
const ocPath = join(dir, "opencode.jsonc")
|
|
92
|
+
let ocChanged = false
|
|
93
|
+
|
|
94
|
+
if (await exists(ocPath)) {
|
|
95
|
+
const cfg = await readJSONC(ocPath)
|
|
96
|
+
ocChanged = mergePlugin(cfg, PLUGIN_SPEC)
|
|
97
|
+
if (ocChanged) {
|
|
98
|
+
await writeFile(ocPath, formatJSONC(cfg))
|
|
99
|
+
console.log(`[opencode-cache-hit-tui] Also added to ${ocPath}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- done ----
|
|
104
|
+
if (tuiChanged || ocChanged) {
|
|
105
|
+
console.log("\nDone! Restart OpenCode to see the Token Cache sidebar panel.")
|
|
106
|
+
} else {
|
|
107
|
+
console.log("\nAlready installed. Restart OpenCode if you haven't yet.")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().catch((err) => {
|
|
112
|
+
console.error("Install failed:", err.message)
|
|
113
|
+
process.exit(1)
|
|
114
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-visual-cache",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./tui": {
|
|
14
|
+
"import": "./src/index.tsx",
|
|
15
|
+
"config": { "enabled": true }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"install.mjs",
|
|
22
|
+
"README.md",
|
|
23
|
+
"README_EN.md"
|
|
24
|
+
],
|
|
25
|
+
"bin": {
|
|
26
|
+
"opencode-visual-cache": "install.mjs"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "tsc"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"opencode",
|
|
35
|
+
"opencode-plugin",
|
|
36
|
+
"tui",
|
|
37
|
+
"token",
|
|
38
|
+
"cache-hit-rate",
|
|
39
|
+
"cost-tracking"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@opencode-ai/plugin": ">=1.14.0",
|
|
44
|
+
"@opencode-ai/sdk": ">=1.14.0",
|
|
45
|
+
"@opentui/core": ">=0.2.0",
|
|
46
|
+
"@opentui/solid": ">=0.2.0",
|
|
47
|
+
"solid-js": ">=1.9.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@opencode-ai/plugin": "^1.14.50",
|
|
51
|
+
"@opencode-ai/sdk": "^1.14.50",
|
|
52
|
+
"typescript": "^5.8.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "@opentui/solid"
|
|
4
|
+
import type {
|
|
5
|
+
TuiPlugin,
|
|
6
|
+
TuiPluginApi,
|
|
7
|
+
TuiSlotContext,
|
|
8
|
+
TuiSlotPlugin,
|
|
9
|
+
TuiPluginModule,
|
|
10
|
+
TuiThemeCurrent,
|
|
11
|
+
} from "@opencode-ai/plugin/tui"
|
|
12
|
+
import type { AssistantMessage, Message } from "@opencode-ai/sdk"
|
|
13
|
+
import { createMemo, createSignal, Show } from "solid-js"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
// Bun / Node globals — available at runtime in the OpenCode TUI process
|
|
20
|
+
declare const process: { env: Record<string, string | undefined> } | undefined
|
|
21
|
+
|
|
22
|
+
// ── terminal-width helpers ────────────────────────────────────────
|
|
23
|
+
// CJK characters occupy 2 terminal columns; padEnd/padStart count
|
|
24
|
+
// string length (=1 per char), which breaks alignment with mixed text.
|
|
25
|
+
|
|
26
|
+
function charColumns(c: string): number {
|
|
27
|
+
const code = c.codePointAt(0) ?? 0
|
|
28
|
+
if (code < 0x20) return 0 // control
|
|
29
|
+
if (code < 0x7F) return 1 // ASCII
|
|
30
|
+
if (code < 0xA0) return 0 // C1 controls
|
|
31
|
+
// East-Asian wide / fullwidth ranges
|
|
32
|
+
if ((code >= 0x1100 && code <= 0x115F) || // Hangul Jamo
|
|
33
|
+
(code >= 0x2E80 && code <= 0xA4CF) || // CJK Radicals … Yi
|
|
34
|
+
(code >= 0xAC00 && code <= 0xD7A3) || // Hangul
|
|
35
|
+
(code >= 0xF900 && code <= 0xFAFF) || // CJK Compat
|
|
36
|
+
(code >= 0xFE10 && code <= 0xFE6F) || // Vertical / Compat
|
|
37
|
+
(code >= 0xFF01 && code <= 0xFF60) || // Fullwidth
|
|
38
|
+
(code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth signs
|
|
39
|
+
(code >= 0x1F300 && code <= 0x1F64F) || // Misc Symbols (emoji)
|
|
40
|
+
(code >= 0x20000 && code <= 0x3FFFD)) // SIP / TIP
|
|
41
|
+
return 2
|
|
42
|
+
return 1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function visualWidth(s: string): number {
|
|
46
|
+
let w = 0; for (const c of s) w += charColumns(c); return w
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function visualPadEnd(s: string, cols: number): string {
|
|
50
|
+
const pad = cols - visualWidth(s)
|
|
51
|
+
return pad > 0 ? s + " ".repeat(pad) : s
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── debug: set CACHE_TUI_LANG=en|zh to override auto-detection ──
|
|
55
|
+
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
56
|
+
|
|
57
|
+
// ── language ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const LANG_ZH = DEBUG_LANG
|
|
60
|
+
? DEBUG_LANG === "zh"
|
|
61
|
+
: (() => {
|
|
62
|
+
try { return Intl.DateTimeFormat().resolvedOptions().locale.startsWith("zh") }
|
|
63
|
+
catch { return false }
|
|
64
|
+
})()
|
|
65
|
+
|
|
66
|
+
const T = LANG_ZH ? {
|
|
67
|
+
title: "缓存统计",
|
|
68
|
+
hit: "命中率",
|
|
69
|
+
read: "缓存读:",
|
|
70
|
+
write: "缓存写:",
|
|
71
|
+
miss: "未命中:",
|
|
72
|
+
out: "输出:",
|
|
73
|
+
cost: "费用:",
|
|
74
|
+
saved: "累计节省:",
|
|
75
|
+
model: "模型:",
|
|
76
|
+
rate: "单价:",
|
|
77
|
+
hitFolded: "命中",
|
|
78
|
+
inputRate: "输入",
|
|
79
|
+
cacheRate: "缓存",
|
|
80
|
+
tok: "tok",
|
|
81
|
+
} as const : {
|
|
82
|
+
title: "Token Cache",
|
|
83
|
+
hit: "Hit",
|
|
84
|
+
read: "Read:",
|
|
85
|
+
write: "Write:",
|
|
86
|
+
miss: "Miss:",
|
|
87
|
+
out: "Out:",
|
|
88
|
+
cost: "Cost:",
|
|
89
|
+
saved: "Total Saved:",
|
|
90
|
+
model: "Model:",
|
|
91
|
+
rate: "Rate:",
|
|
92
|
+
hitFolded: "hit",
|
|
93
|
+
inputRate: "in",
|
|
94
|
+
cacheRate: "cache",
|
|
95
|
+
tok: "tok",
|
|
96
|
+
} as const
|
|
97
|
+
|
|
98
|
+
// ── color helpers ────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
|
|
101
|
+
function rgb(raw: unknown): { r: number; g: number; b: number } | null {
|
|
102
|
+
if (typeof raw === "string" && raw.startsWith("#")) {
|
|
103
|
+
const h = raw.slice(1)
|
|
104
|
+
return {
|
|
105
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
106
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
107
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (raw && typeof raw === "object") {
|
|
111
|
+
const o = raw as Record<string, unknown>
|
|
112
|
+
if (typeof o.r === "number" && typeof o.g === "number" && typeof o.b === "number") {
|
|
113
|
+
// RGBA channels may be 0-1 floats; detect and upscale.
|
|
114
|
+
const scale = o.r > 1 || o.g > 1 || o.b > 1 ? 1 : 255
|
|
115
|
+
return {
|
|
116
|
+
r: Math.round(o.r * scale),
|
|
117
|
+
g: Math.round(o.g * scale),
|
|
118
|
+
b: Math.round(o.b * scale),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** HSL saturation of an RGB color (0–1). */
|
|
126
|
+
function saturation(r: number, g: number, b: number): number {
|
|
127
|
+
const max = Math.max(r, g, b) / 255
|
|
128
|
+
const min = Math.min(r, g, b) / 255
|
|
129
|
+
const delta = max - min
|
|
130
|
+
if (delta === 0) return 0
|
|
131
|
+
const L = (max + min) / 2
|
|
132
|
+
return L <= 0.5 ? delta / (max + min) : delta / (2 - max - min)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* If the colour's saturation exceeds `maxSat`, pull it toward grey
|
|
137
|
+
* until saturation drops to maxSat. Returns a hex string.
|
|
138
|
+
*/
|
|
139
|
+
function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
140
|
+
const c = rgb(raw)
|
|
141
|
+
if (!c) return fallback
|
|
142
|
+
const sat = saturation(c.r, c.g, c.b)
|
|
143
|
+
if (sat <= maxSat) {
|
|
144
|
+
// already muted — return as hex
|
|
145
|
+
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
146
|
+
}
|
|
147
|
+
// binary search the right amount of grey to mix in
|
|
148
|
+
let lo = 0, hi = 1
|
|
149
|
+
for (let i = 0; i < 12; i++) {
|
|
150
|
+
const mid = (lo + hi) / 2
|
|
151
|
+
const nr = Math.round(c.r + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.r) * mid)
|
|
152
|
+
const ng = Math.round(c.g + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.g) * mid)
|
|
153
|
+
const nb = Math.round(c.b + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.b) * mid)
|
|
154
|
+
if (saturation(nr, ng, nb) > maxSat) lo = mid
|
|
155
|
+
else hi = mid
|
|
156
|
+
}
|
|
157
|
+
const grey = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
158
|
+
const nr = Math.round(c.r + (grey - c.r) * hi)
|
|
159
|
+
const ng = Math.round(c.g + (grey - c.g) * hi)
|
|
160
|
+
const nb = Math.round(c.b + (grey - c.b) * hi)
|
|
161
|
+
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Morandi fallbacks — used when a theme colour cannot be resolved
|
|
165
|
+
const FALLBACK = {
|
|
166
|
+
primary: "#8B9DAF",
|
|
167
|
+
text: "#C5C5BB",
|
|
168
|
+
muted: "#7A7A72",
|
|
169
|
+
success: "#9CAF8B",
|
|
170
|
+
warning: "#C5B88D",
|
|
171
|
+
error: "#B08A8A",
|
|
172
|
+
border: "#6B6B63",
|
|
173
|
+
} as const
|
|
174
|
+
|
|
175
|
+
/** Morandi ceiling — colours above this saturation get pulled down. */
|
|
176
|
+
const MAX_SAT = 0.28
|
|
177
|
+
|
|
178
|
+
function progressBar(percent: number, width: number): string {
|
|
179
|
+
const clamped = Math.max(0, Math.min(100, percent))
|
|
180
|
+
const filled = Math.round((clamped / 100) * width)
|
|
181
|
+
const empty = Math.max(0, width - filled)
|
|
182
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function fmt(n: number): string {
|
|
186
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
|
|
187
|
+
if (n >= 10_000) return Math.round(n / 1_000) + "K"
|
|
188
|
+
return n.toLocaleString("en-US")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function num(v: unknown): number {
|
|
192
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function fmtCost(n: number): string {
|
|
196
|
+
if (n >= 1) return "$" + n.toFixed(2)
|
|
197
|
+
if (n >= 0.01) return "$" + n.toFixed(3)
|
|
198
|
+
return "$" + n.toFixed(4)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Sidebar component
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
const MIN_PANEL_WIDTH = 20
|
|
206
|
+
const DEFAULT_PANEL_WIDTH = 26
|
|
207
|
+
/** Horizontal space eaten by border (1+1) + padding (2+2). */
|
|
208
|
+
const GUTTER = 2 + 4
|
|
209
|
+
|
|
210
|
+
function TokenCachePanel(props: {
|
|
211
|
+
theme: TuiThemeCurrent
|
|
212
|
+
api: TuiPluginApi
|
|
213
|
+
sessionId: string
|
|
214
|
+
}): JSX.Element {
|
|
215
|
+
const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
|
|
216
|
+
const [open, setOpen] = createSignal(true)
|
|
217
|
+
|
|
218
|
+
// ── scan session messages reactively ──
|
|
219
|
+
// SolidJS createMemo re-evaluates whenever the underlying
|
|
220
|
+
// api.state.session state changes — no event listener needed.
|
|
221
|
+
const data = createMemo(() => {
|
|
222
|
+
const msgs = props.api.state.session.messages(props.sessionId) as Message[]
|
|
223
|
+
|
|
224
|
+
let input = 0
|
|
225
|
+
let read = 0
|
|
226
|
+
let write = 0
|
|
227
|
+
let output = 0
|
|
228
|
+
let cost = 0
|
|
229
|
+
let pid = ""
|
|
230
|
+
let mid = ""
|
|
231
|
+
|
|
232
|
+
for (const msg of msgs) {
|
|
233
|
+
if (msg.role !== "assistant") continue
|
|
234
|
+
const t = (msg as AssistantMessage).tokens
|
|
235
|
+
input += num(t.input)
|
|
236
|
+
read += num(t.cache?.read)
|
|
237
|
+
write += num(t.cache?.write)
|
|
238
|
+
output += num(t.output)
|
|
239
|
+
cost += num((msg as AssistantMessage).cost)
|
|
240
|
+
if ((msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
|
|
241
|
+
pid = (msg as AssistantMessage).providerID
|
|
242
|
+
mid = (msg as AssistantMessage).modelID
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// cost savings from cache hits
|
|
247
|
+
let saved = 0
|
|
248
|
+
let inputRate = 0
|
|
249
|
+
let cacheReadRate = 0
|
|
250
|
+
if (read > 0 && pid && mid) {
|
|
251
|
+
for (const provider of props.api.state.provider) {
|
|
252
|
+
if (provider.id !== pid) continue
|
|
253
|
+
const model = provider.models[mid]
|
|
254
|
+
if (!model?.cost) continue
|
|
255
|
+
inputRate = num(model.cost.input)
|
|
256
|
+
cacheReadRate = num(model.cost.cache?.read)
|
|
257
|
+
const diff = inputRate - cacheReadRate
|
|
258
|
+
if (diff > 0) saved = (read * diff) / 1_000_000
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// `input` from the API represents fresh (non-cached) tokens.
|
|
264
|
+
// Total context = fresh + cache.read.
|
|
265
|
+
const totalInput = input + read
|
|
266
|
+
const hitRate = totalInput > 0 ? (read / totalInput) * 100 : 0
|
|
267
|
+
const model = mid.split("/").pop() ?? mid
|
|
268
|
+
const hasPricing = inputRate > 0 || cacheReadRate > 0
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
hitRate, read, write, freshInput: input, output,
|
|
272
|
+
cost, saved, model, inputRate, cacheReadRate, hasPricing,
|
|
273
|
+
hasData: read > 0 || write > 0 || cost > 0,
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const d = () => data()
|
|
278
|
+
|
|
279
|
+
// ── colours ──
|
|
280
|
+
// Pull from the current theme, auto-desaturate if too punchy,
|
|
281
|
+
// fall back to Morandi when a key is missing from the theme.
|
|
282
|
+
const pal = createMemo(() => {
|
|
283
|
+
const t = props.theme as Record<string, unknown>
|
|
284
|
+
const sat = (k: string, fb: string) => desaturateTo(t[k], MAX_SAT, fb)
|
|
285
|
+
return {
|
|
286
|
+
primary: sat("primary", FALLBACK.primary),
|
|
287
|
+
text: sat("text", FALLBACK.text),
|
|
288
|
+
muted: sat("textMuted", FALLBACK.muted),
|
|
289
|
+
success: sat("success", FALLBACK.success),
|
|
290
|
+
warning: sat("warning", FALLBACK.warning),
|
|
291
|
+
error: sat("error", FALLBACK.error),
|
|
292
|
+
border: sat("border", FALLBACK.border),
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const hitColor = createMemo(() => {
|
|
297
|
+
const r = d().hitRate
|
|
298
|
+
if (r >= 85) return pal().success
|
|
299
|
+
if (r >= 70) return pal().warning
|
|
300
|
+
return pal().error
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - GUTTER)))
|
|
304
|
+
const barW = createMemo(() => {
|
|
305
|
+
const overhead = visualWidth(T.hit) + 1 + 2 + 1 + /*pct*/5 + GUTTER
|
|
306
|
+
return Math.max(3, panelWidth() - overhead)
|
|
307
|
+
})
|
|
308
|
+
const bar = createMemo(() => progressBar(d().hitRate, barW()))
|
|
309
|
+
const pct = createMemo(() => d().hitRate.toFixed(1) + "%")
|
|
310
|
+
|
|
311
|
+
// left-align label, right-align value — auto-fill space between
|
|
312
|
+
const justify = (label: string, value: string, unit = ""): string => {
|
|
313
|
+
const gauge = panelWidth() - GUTTER
|
|
314
|
+
const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + 1 : 0)
|
|
315
|
+
const gap = Math.max(1, gauge - used)
|
|
316
|
+
return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<Show when={d().hasData}>
|
|
321
|
+
<box
|
|
322
|
+
border
|
|
323
|
+
borderColor={pal().border}
|
|
324
|
+
paddingTop={0}
|
|
325
|
+
paddingBottom={0}
|
|
326
|
+
paddingLeft={2}
|
|
327
|
+
paddingRight={2}
|
|
328
|
+
flexDirection="column"
|
|
329
|
+
gap={0}
|
|
330
|
+
onSizeChange={function () {
|
|
331
|
+
const w = Math.max(MIN_PANEL_WIDTH, this.width)
|
|
332
|
+
setPanelWidth((prev) => (prev === w ? prev : w))
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
{/* collapsible header */}
|
|
336
|
+
<text onMouseUp={() => setOpen((o) => !o)}>
|
|
337
|
+
<span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
338
|
+
<span style={{ fg: pal().primary }}><b>{T.title}</b></span>
|
|
339
|
+
<Show when={!open()}>
|
|
340
|
+
<span>
|
|
341
|
+
{" ".repeat(Math.max(1, panelWidth() - GUTTER - 2 - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
|
|
342
|
+
</span>
|
|
343
|
+
<span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
|
|
344
|
+
</Show>
|
|
345
|
+
</text>
|
|
346
|
+
|
|
347
|
+
<Show when={open()}>
|
|
348
|
+
<text fg={pal().muted}>{sep()}</text>
|
|
349
|
+
|
|
350
|
+
{/* hit rate + bar — inline to avoid box spacing */}
|
|
351
|
+
<text>
|
|
352
|
+
<span style={{ fg: pal().text }}>{T.hit} </span>
|
|
353
|
+
<span style={{ fg: hitColor() }}>[{bar()}] </span>
|
|
354
|
+
<span style={{ fg: pal().text }}>{pct()}</span>
|
|
355
|
+
</text>
|
|
356
|
+
|
|
357
|
+
{/* token breakdown */}
|
|
358
|
+
<text fg={pal().muted}>
|
|
359
|
+
{justify(T.read, fmt(d().read), T.tok)}
|
|
360
|
+
</text>
|
|
361
|
+
<text fg={pal().muted}>
|
|
362
|
+
{justify(T.write, fmt(d().write), T.tok)}
|
|
363
|
+
</text>
|
|
364
|
+
<text fg={pal().muted}>
|
|
365
|
+
{justify(T.miss, fmt(d().freshInput), T.tok)}
|
|
366
|
+
</text>
|
|
367
|
+
<text fg={pal().muted}>
|
|
368
|
+
{justify(T.out, fmt(d().output), T.tok)}
|
|
369
|
+
</text>
|
|
370
|
+
|
|
371
|
+
<text fg={pal().muted}>{sep()}</text>
|
|
372
|
+
|
|
373
|
+
{/* cost */}
|
|
374
|
+
<text fg={pal().text}>
|
|
375
|
+
{justify(T.cost, fmtCost(d().cost))}
|
|
376
|
+
</text>
|
|
377
|
+
|
|
378
|
+
{/* saved */}
|
|
379
|
+
<Show when={d().saved > 0}>
|
|
380
|
+
<text>
|
|
381
|
+
<span style={{ fg: pal().muted }}>{T.saved}</span>
|
|
382
|
+
<span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(d().saved))))}</span>
|
|
383
|
+
<span style={{ fg: pal().success }}>~{fmtCost(d().saved)}</span>
|
|
384
|
+
</text>
|
|
385
|
+
</Show>
|
|
386
|
+
|
|
387
|
+
{/* model */}
|
|
388
|
+
<text fg={pal().muted}>
|
|
389
|
+
{justify(T.model, d().model)}
|
|
390
|
+
</text>
|
|
391
|
+
|
|
392
|
+
{/* rates */}
|
|
393
|
+
<Show when={d().hasPricing}>
|
|
394
|
+
<text fg={pal().muted}>
|
|
395
|
+
{justify(T.rate, "$" + d().inputRate.toFixed(2) + "/M " + T.inputRate)}
|
|
396
|
+
</text>
|
|
397
|
+
<Show when={d().cacheReadRate > 0}>
|
|
398
|
+
<text fg={pal().muted}>
|
|
399
|
+
{justify("", "$" + d().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
|
|
400
|
+
</text>
|
|
401
|
+
</Show>
|
|
402
|
+
</Show>
|
|
403
|
+
</Show>
|
|
404
|
+
</box>
|
|
405
|
+
</Show>
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Plugin entry
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
|
|
414
|
+
return {
|
|
415
|
+
order: 55,
|
|
416
|
+
slots: {
|
|
417
|
+
sidebar_content(ctx: TuiSlotContext, input: { session_id: string }): JSX.Element {
|
|
418
|
+
return (
|
|
419
|
+
<TokenCachePanel
|
|
420
|
+
theme={ctx.theme.current}
|
|
421
|
+
api={api}
|
|
422
|
+
sessionId={input.session_id}
|
|
423
|
+
/>
|
|
424
|
+
)
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const tui: TuiPlugin = async (api: TuiPluginApi) => {
|
|
431
|
+
api.slots.register(createSidebarSlot(api))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const mod: TuiPluginModule & { id: string } = {
|
|
435
|
+
id: "opencode-visual-cache",
|
|
436
|
+
tui,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export default mod
|