opencode-visual-cache 1.1.6 → 1.2.3
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 +10 -2
- package/README_EN.md +11 -3
- package/dist/_version.d.ts +1 -1
- package/dist/_version.js +1 -1
- package/dist/index.js +237 -15
- package/package.json +2 -1
- package/src/_version.ts +1 -1
- package/src/index.tsx +325 -60
package/README.md
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
自适应主题色 · 自动低饱和设计语言 · 支持中/英双语
|
|
6
6
|
</strong>
|
|
7
7
|
<br>
|
|
8
|
+
<br>
|
|
9
|
+
如果你觉得这个插件不错的话,可以帮我点点小星星 ⭐,谢谢!<br>
|
|
10
|
+
<br>
|
|
8
11
|
|
|
12
|
+
[](https://github.com/Hotakus/opencode-visual-cache)
|
|
9
13
|
[](https://github.com/Hotakus/opencode-visual-cache/stargazers)
|
|
10
|
-
[](LICENSE)
|
|
11
|
-
[](README_EN.md)
|
|
14
|
+
[](LICENSE)
|
|
15
|
+
[](https://github.com/Hotakus/opencode-visual-cache/blob/master/README_EN.md)
|
|
16
|
+

|
|
12
17
|
|
|
13
18
|
</div>
|
|
14
19
|
|
|
@@ -37,6 +42,9 @@
|
|
|
37
42
|
- **模型定价**:显示当前模型的输入 / 缓存读单价(从 provider 配置动态读取)
|
|
38
43
|
- **折叠面板**:点击标题折叠为一行,节省侧边栏空间
|
|
39
44
|
- **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
|
|
45
|
+
- **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示可解析 Token 占比
|
|
46
|
+
- **区块折叠**:明细、模型、Token 分布各自独立折叠
|
|
47
|
+
- **折叠记忆**:折叠状态持久化,重启后保持
|
|
40
48
|
- **语言适配**:自动检测系统语言
|
|
41
49
|
|
|
42
50
|
---
|
package/README_EN.md
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
Adaptive Theme Colors · Auto-desaturated · Chinese / English
|
|
6
6
|
</strong>
|
|
7
7
|
<br>
|
|
8
|
+
<br>
|
|
9
|
+
If you find this plugin useful, a ⭐ would mean a lot — thank you!<br>
|
|
10
|
+
<br>
|
|
8
11
|
|
|
12
|
+
[](https://github.com/Hotakus/opencode-visual-cache)
|
|
9
13
|
[](https://github.com/Hotakus/opencode-visual-cache/stargazers)
|
|
10
|
-
[](LICENSE)
|
|
11
|
-
[](LICENSE)
|
|
15
|
+
[](https://github.com/Hotakus/opencode-visual-cache/blob/master/README.md)
|
|
16
|
+

|
|
12
17
|
|
|
13
18
|
</div>
|
|
14
19
|
|
|
@@ -37,7 +42,10 @@
|
|
|
37
42
|
- **Model Pricing**: Current model's input / cache-read rates (from provider config)
|
|
38
43
|
- **Collapsible**: Click title to fold into one line
|
|
39
44
|
- **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from theme
|
|
40
|
-
- **
|
|
45
|
+
- **Token Distribution**: Per-role (system / user / agent instr / tool call / tool result) parseable token breakdown
|
|
46
|
+
- **Collapsible Sections**: Detail, model, and token distribution fold independently
|
|
47
|
+
- **Persistent Fold State**: Fold preferences remembered across restarts
|
|
48
|
+
- **Language**: Auto-detects system locale
|
|
41
49
|
|
|
42
50
|
---
|
|
43
51
|
|
package/dist/_version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const PLUGIN_VERSION = "1.
|
|
1
|
+
export declare const PLUGIN_VERSION = "1.2.3";
|
package/dist/_version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// auto-generated
|
|
2
|
-
export const PLUGIN_VERSION = "1.
|
|
2
|
+
export const PLUGIN_VERSION = "1.2.3";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
|
|
2
|
-
import { createMemo, createSignal, Show } from "solid-js";
|
|
2
|
+
import { createMemo, createSignal, onMount, onCleanup, Show } from "solid-js";
|
|
3
3
|
import { PLUGIN_VERSION } from "./_version";
|
|
4
4
|
// ── terminal-width helpers ────────────────────────────────────────
|
|
5
5
|
// CJK characters occupy 2 terminal columns; padEnd/padStart count
|
|
@@ -35,7 +35,7 @@ function visualPadEnd(s, cols) {
|
|
|
35
35
|
const pad = cols - visualWidth(s);
|
|
36
36
|
return pad > 0 ? s + " ".repeat(pad) : s;
|
|
37
37
|
}
|
|
38
|
-
// ──
|
|
38
|
+
// ── language override (env: CACHE_TUI_LANG) ──
|
|
39
39
|
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined;
|
|
40
40
|
// ── language ──────────────────────────────────────────────────────
|
|
41
41
|
const LANG_ZH = DEBUG_LANG
|
|
@@ -67,6 +67,16 @@ const T = LANG_ZH ? {
|
|
|
67
67
|
writeRate: "写入",
|
|
68
68
|
noData: "等待缓存数据...",
|
|
69
69
|
tok: "tok",
|
|
70
|
+
distTitle: "估算 Token 分布",
|
|
71
|
+
distSys: "系统提示:",
|
|
72
|
+
distUser: "用户:",
|
|
73
|
+
distAgent: "Agent 指令:",
|
|
74
|
+
distTool: "Tool 调用:",
|
|
75
|
+
distRes: "Tool 结果:",
|
|
76
|
+
distTotal: "总计:",
|
|
77
|
+
distOut: "输出:",
|
|
78
|
+
secDetail: "明细",
|
|
79
|
+
secModel: "模型",
|
|
70
80
|
} : {
|
|
71
81
|
title: "Token Cache",
|
|
72
82
|
hit: "Hit",
|
|
@@ -86,6 +96,16 @@ const T = LANG_ZH ? {
|
|
|
86
96
|
writeRate: "write",
|
|
87
97
|
noData: "Waiting for cache data...",
|
|
88
98
|
tok: "tok",
|
|
99
|
+
distTitle: "Estimated Token Dist.",
|
|
100
|
+
distSys: "System:",
|
|
101
|
+
distUser: "User:",
|
|
102
|
+
distAgent: "Agent Instr:",
|
|
103
|
+
distTool: "Tool Call:",
|
|
104
|
+
distRes: "Tool Result:",
|
|
105
|
+
distTotal: "Total:",
|
|
106
|
+
distOut: "Output:",
|
|
107
|
+
secDetail: "Detail",
|
|
108
|
+
secModel: "Model",
|
|
89
109
|
};
|
|
90
110
|
// ── color helpers ────────────────────────────────────────────────
|
|
91
111
|
/** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
|
|
@@ -135,22 +155,32 @@ function desaturateTo(raw, maxSat, fallback) {
|
|
|
135
155
|
// already muted — return as hex
|
|
136
156
|
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("");
|
|
137
157
|
}
|
|
138
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Binary search for the optimal grey-mix ratio α (0…1).
|
|
160
|
+
*
|
|
161
|
+
* 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
|
|
162
|
+
* channels are only 0–255 (8 bit), so 8 iterations (1/256) would
|
|
163
|
+
* technically suffice; 12 is intentionally over-budget — the extra
|
|
164
|
+
* precision costs almost nothing and guarantees the saturation probe
|
|
165
|
+
* converges to within a fraction of an 8‑bit step, eliminating
|
|
166
|
+
* colour banding in edge cases.
|
|
167
|
+
*/
|
|
168
|
+
// BT.601 luma (perceptual brightness used as the grey anchor)
|
|
169
|
+
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
|
|
139
170
|
let lo = 0, hi = 1;
|
|
140
171
|
for (let i = 0; i < 12; i++) {
|
|
141
172
|
const mid = (lo + hi) / 2;
|
|
142
|
-
const nr = Math.round(c.r + (
|
|
143
|
-
const ng = Math.round(c.g + (
|
|
144
|
-
const nb = Math.round(c.b + (
|
|
173
|
+
const nr = Math.round(c.r + (luma - c.r) * mid);
|
|
174
|
+
const ng = Math.round(c.g + (luma - c.g) * mid);
|
|
175
|
+
const nb = Math.round(c.b + (luma - c.b) * mid);
|
|
145
176
|
if (saturation(nr, ng, nb) > maxSat)
|
|
146
177
|
lo = mid;
|
|
147
178
|
else
|
|
148
179
|
hi = mid;
|
|
149
180
|
}
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const nb = Math.round(c.b + (grey - c.b) * hi);
|
|
181
|
+
const nr = Math.round(c.r + (luma - c.r) * hi);
|
|
182
|
+
const ng = Math.round(c.g + (luma - c.g) * hi);
|
|
183
|
+
const nb = Math.round(c.b + (luma - c.b) * hi);
|
|
154
184
|
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("");
|
|
155
185
|
}
|
|
156
186
|
// Morandi fallbacks — used when a theme colour cannot be resolved
|
|
@@ -163,7 +193,18 @@ const FALLBACK = {
|
|
|
163
193
|
error: "#B08A8A",
|
|
164
194
|
border: "#6B6B63",
|
|
165
195
|
};
|
|
166
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Desaturation ceiling for the Morandi-style palette.
|
|
198
|
+
*
|
|
199
|
+
* Morandi colours float around 0.15–0.30 saturation in HSL space.
|
|
200
|
+
* 0.28 sits near the upper end of that range: it strips the aggressive
|
|
201
|
+
* punch from high-saturation themes (Dracula, Solarized …) while
|
|
202
|
+
* preserving enough colour identity that green / orange / red hit-rate
|
|
203
|
+
* coding stays distinguishable.
|
|
204
|
+
*
|
|
205
|
+
* Lower → more grey, harder to tell colours apart.
|
|
206
|
+
* Higher → bright themes bleed through and defeat the muted look.
|
|
207
|
+
*/
|
|
167
208
|
const MAX_SAT = 0.28;
|
|
168
209
|
function progressBar(percent, width) {
|
|
169
210
|
const clamped = Math.max(0, Math.min(100, percent));
|
|
@@ -188,6 +229,43 @@ function fmtCost(n) {
|
|
|
188
229
|
return "$" + n.toFixed(3);
|
|
189
230
|
return "$" + n.toFixed(4);
|
|
190
231
|
}
|
|
232
|
+
// ── token estimation ──
|
|
233
|
+
// Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
|
|
234
|
+
// chars per token) work well for natural language but systematically
|
|
235
|
+
// under-count tokens in JSON and source code where every punctuation mark
|
|
236
|
+
// tends to be its own token. Detect these cases and tighten the ratio.
|
|
237
|
+
// See: GPT-4 / Claude tokenizer behaviour with structured text.
|
|
238
|
+
function estimateTokens(text) {
|
|
239
|
+
if (!text || text.length === 0)
|
|
240
|
+
return 0;
|
|
241
|
+
let ascii = 0;
|
|
242
|
+
let cjk = 0;
|
|
243
|
+
for (const c of text) {
|
|
244
|
+
const code = c.codePointAt(0) ?? 0;
|
|
245
|
+
if (code >= 0x4E00 && code <= 0x9FFF)
|
|
246
|
+
cjk++; // CJK Unified
|
|
247
|
+
else if (code >= 0x3040 && code <= 0x30FF)
|
|
248
|
+
cjk++; // Hiragana/Katakana
|
|
249
|
+
else if (code >= 0xAC00 && code <= 0xD7A3)
|
|
250
|
+
cjk++; // Hangul
|
|
251
|
+
else if (code >= 0x1100 && code <= 0x11FF)
|
|
252
|
+
cjk++; // Hangul Jamo
|
|
253
|
+
else if (code >= 0x2E80 && code <= 0x2EFF)
|
|
254
|
+
cjk++; // CJK Radicals
|
|
255
|
+
else
|
|
256
|
+
ascii++;
|
|
257
|
+
}
|
|
258
|
+
// Tighten the ASCII ratio for structured content where punctuation is
|
|
259
|
+
// token-dense. JSON key-value patterns and code keywords are strong
|
|
260
|
+
// signals that the default 4:1 ratio will materially under-estimate.
|
|
261
|
+
const trimmed = text.trimStart();
|
|
262
|
+
const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("["))
|
|
263
|
+
&& /"[^"]+"\s*:/.test(text);
|
|
264
|
+
const codeLike = !jsonLike
|
|
265
|
+
&& /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
|
|
266
|
+
const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
|
|
267
|
+
return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
|
|
268
|
+
}
|
|
191
269
|
// ---------------------------------------------------------------------------
|
|
192
270
|
// Sidebar component
|
|
193
271
|
// ---------------------------------------------------------------------------
|
|
@@ -205,6 +283,9 @@ const UNIT_GAP = 1; // 计量单位前的空格(如 "tok")
|
|
|
205
283
|
function TokenCachePanel(props) {
|
|
206
284
|
const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH);
|
|
207
285
|
const [open, setOpen] = createSignal(true);
|
|
286
|
+
const [detailOpen, setDetailOpen] = createSignal(true);
|
|
287
|
+
const [modelOpen, setModelOpen] = createSignal(true);
|
|
288
|
+
const [distOpen, setDistOpen] = createSignal(false);
|
|
208
289
|
let boxEl;
|
|
209
290
|
// ── scan session messages reactively ──
|
|
210
291
|
// SolidJS createMemo re-evaluates whenever the underlying
|
|
@@ -267,8 +348,8 @@ function TokenCachePanel(props) {
|
|
|
267
348
|
// `input` from the API represents fresh (non-cached) tokens.
|
|
268
349
|
const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0;
|
|
269
350
|
// Total context = fresh + cache.read.
|
|
270
|
-
const
|
|
271
|
-
const sessionHitRate =
|
|
351
|
+
const freshTotal = input + read;
|
|
352
|
+
const sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0;
|
|
272
353
|
const model = mid.split("/").pop() ?? mid;
|
|
273
354
|
const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
|
|
274
355
|
let trend = 0;
|
|
@@ -277,6 +358,119 @@ function TokenCachePanel(props) {
|
|
|
277
358
|
trend = lastMsgHitRate - prevMsgHitRate;
|
|
278
359
|
}
|
|
279
360
|
const providerName = pid || "";
|
|
361
|
+
// ── token distribution (in-process via api.state.part) ──
|
|
362
|
+
// Wrapped in try-catch so a part fetching failure never crashes the panel.
|
|
363
|
+
let dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 };
|
|
364
|
+
let hasDistData = false;
|
|
365
|
+
try {
|
|
366
|
+
partVersion(); // track part changes for reactivity
|
|
367
|
+
dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 };
|
|
368
|
+
// Read agent system prompt once before the message loop. Reading it
|
|
369
|
+
// inside the per-user-message branch risks transient unavailability
|
|
370
|
+
// (api.state.config not yet resolved during streaming) silently
|
|
371
|
+
// resetting a previously-computed value and causing display flicker.
|
|
372
|
+
try {
|
|
373
|
+
const session = props.api.state.session.get(props.sessionId);
|
|
374
|
+
const cfg = props.api.state.config;
|
|
375
|
+
const agentName = String(session?.agent ?? cfg?.default_agent ?? "build");
|
|
376
|
+
const agents = cfg?.agent;
|
|
377
|
+
const agentCfg = agents?.[agentName];
|
|
378
|
+
const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : "";
|
|
379
|
+
if (sysPrompt)
|
|
380
|
+
dist.system = estimateTokens(sysPrompt);
|
|
381
|
+
}
|
|
382
|
+
catch { }
|
|
383
|
+
for (const msg of msgs) {
|
|
384
|
+
if (msg.role === "user") {
|
|
385
|
+
const um = msg;
|
|
386
|
+
if (um.system)
|
|
387
|
+
dist.system += estimateTokens(um.system);
|
|
388
|
+
let parts = [];
|
|
389
|
+
try {
|
|
390
|
+
parts = props.api.state.part(msg.id);
|
|
391
|
+
}
|
|
392
|
+
catch { }
|
|
393
|
+
for (const p of parts) {
|
|
394
|
+
if (p.type === "text" && !p.synthetic && !p.ignored) {
|
|
395
|
+
dist.user += estimateTokens(p.text);
|
|
396
|
+
}
|
|
397
|
+
else if (p.type === "file") {
|
|
398
|
+
const fp = p;
|
|
399
|
+
if (fp.source?.text?.value)
|
|
400
|
+
dist.user += estimateTokens(fp.source.text.value);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else if (msg.role === "assistant") {
|
|
405
|
+
const am = msg;
|
|
406
|
+
dist.output += num(am.tokens?.output);
|
|
407
|
+
let parts = [];
|
|
408
|
+
try {
|
|
409
|
+
parts = props.api.state.part(msg.id);
|
|
410
|
+
}
|
|
411
|
+
catch { }
|
|
412
|
+
for (const p of parts) {
|
|
413
|
+
if (p.type === "tool") {
|
|
414
|
+
const tp = p;
|
|
415
|
+
// Tool call input (params)
|
|
416
|
+
let rawInput = "";
|
|
417
|
+
try {
|
|
418
|
+
rawInput = tp.state.raw ?? JSON.stringify(tp.state.input);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
try {
|
|
422
|
+
rawInput = JSON.stringify(tp.state);
|
|
423
|
+
}
|
|
424
|
+
catch { }
|
|
425
|
+
}
|
|
426
|
+
if (rawInput)
|
|
427
|
+
dist.toolCall += estimateTokens(rawInput);
|
|
428
|
+
// Tool result output
|
|
429
|
+
if (tp.state.status === "completed") {
|
|
430
|
+
const completed = tp.state;
|
|
431
|
+
if (completed.output)
|
|
432
|
+
dist.toolResult += estimateTokens(completed.output);
|
|
433
|
+
}
|
|
434
|
+
else if (tp.state.status === "error") {
|
|
435
|
+
const errored = tp.state;
|
|
436
|
+
if (errored.error)
|
|
437
|
+
dist.toolResult += estimateTokens(errored.error);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else if (p.type === "reasoning") {
|
|
441
|
+
dist.agent += estimateTokens(p.text);
|
|
442
|
+
}
|
|
443
|
+
else if (p.type === "subtask") {
|
|
444
|
+
const sub = p;
|
|
445
|
+
dist.agent += estimateTokens(sub.prompt || sub.description || "");
|
|
446
|
+
}
|
|
447
|
+
else if (p.type === "step-finish") {
|
|
448
|
+
// StepFinishPart carries API-exact per-call token counts.
|
|
449
|
+
// Sum across all step-finish parts (one per API call in tool loops).
|
|
450
|
+
const sf = p;
|
|
451
|
+
dist.apiInput += sf.tokens?.input ?? 0;
|
|
452
|
+
dist.apiOutput += sf.tokens?.output ?? 0;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult;
|
|
458
|
+
const apiTotalInput = dist.apiInput;
|
|
459
|
+
// Use API output if available (StepFinishPart is more accurate than AssistantMessage.tokens)
|
|
460
|
+
const finalOutput = dist.apiOutput > 0 ? dist.apiOutput : dist.output;
|
|
461
|
+
// Gap inference: the SDK does not expose per-part token counts, so any
|
|
462
|
+
// API-exact input total that exceeds the locally-estimated sum is attributed
|
|
463
|
+
// to system prompt / agent config / tool-definition overhead. Add it to the
|
|
464
|
+
// system bucket rather than replacing the local estimate.
|
|
465
|
+
const overhead = Math.max(0, apiTotalInput - totalInput);
|
|
466
|
+
if (overhead >= 50) {
|
|
467
|
+
dist.system += overhead;
|
|
468
|
+
}
|
|
469
|
+
hasDistData = totalInput > 0 || finalOutput > 0 || apiTotalInput > 0;
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// Graceful degradation — dist stays at zeroes
|
|
473
|
+
}
|
|
280
474
|
return {
|
|
281
475
|
hitRate, read, write, freshInput: input, output,
|
|
282
476
|
cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
|
|
@@ -284,8 +478,36 @@ function TokenCachePanel(props) {
|
|
|
284
478
|
trend, hasTrendData,
|
|
285
479
|
providerName,
|
|
286
480
|
sessionHitRate,
|
|
481
|
+
dist, hasDistData,
|
|
287
482
|
};
|
|
288
483
|
});
|
|
484
|
+
// ── token distribution (in-process via api.state.part) ──
|
|
485
|
+
const [partVersion, setPartVersion] = createSignal(0);
|
|
486
|
+
// Persist fold state to api.kv
|
|
487
|
+
const KV_PREFIX = "cache_panel";
|
|
488
|
+
const persistFold = (key, val) => {
|
|
489
|
+
try {
|
|
490
|
+
props.api.kv.set(`${KV_PREFIX}.${key}`, val);
|
|
491
|
+
}
|
|
492
|
+
catch { }
|
|
493
|
+
};
|
|
494
|
+
onMount(() => {
|
|
495
|
+
// Restore fold state from persisted storage
|
|
496
|
+
try {
|
|
497
|
+
setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, true)));
|
|
498
|
+
setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)));
|
|
499
|
+
setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)));
|
|
500
|
+
setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
|
|
501
|
+
}
|
|
502
|
+
catch { }
|
|
503
|
+
const unsubPart = props.api.event.on("message.part.updated", () => {
|
|
504
|
+
setPartVersion((v) => v + 1);
|
|
505
|
+
});
|
|
506
|
+
const unsubMsg = props.api.event.on("message.updated", () => {
|
|
507
|
+
setPartVersion((v) => v + 1);
|
|
508
|
+
});
|
|
509
|
+
onCleanup(() => { unsubPart(); unsubMsg(); });
|
|
510
|
+
});
|
|
289
511
|
// ── colours ──
|
|
290
512
|
// Pull from the current theme, auto-desaturate if too punchy,
|
|
291
513
|
// fall back to Morandi when a key is missing from the theme.
|
|
@@ -320,7 +542,7 @@ function TokenCachePanel(props) {
|
|
|
320
542
|
return Math.max(3, panelWidth() - overhead);
|
|
321
543
|
});
|
|
322
544
|
const bar = createMemo(() => progressBar(data().hitRate, barW()));
|
|
323
|
-
const pct = createMemo(() => data().hitRate.toFixed(1) + "%");
|
|
545
|
+
const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%");
|
|
324
546
|
// left-align label, right-align value — auto-fill space between
|
|
325
547
|
const justify = (label, value, unit = "") => {
|
|
326
548
|
const gauge = panelWidth() - GUTTER;
|
|
@@ -332,7 +554,7 @@ function TokenCachePanel(props) {
|
|
|
332
554
|
// 通过 ref 获取 Renderable 实例宽度
|
|
333
555
|
const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width) : DEFAULT_PANEL_WIDTH;
|
|
334
556
|
setPanelWidth((prev) => (prev === w ? prev : w));
|
|
335
|
-
}, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => !o), 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, data().sessionHitRate.toFixed(1) + "%") }),
|
|
557
|
+
}, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: T.title }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: pal().muted }, children: [" (v", PLUGIN_VERSION, ")"] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] }), _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] })] }), _jsx(Show, { when: open(), children: _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: T.noData })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [T.hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secDetail }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail)) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.read, fmt(data().read), T.tok) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(data().write), T.tok) }) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(data().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(data().output), T.tok) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: T.saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved)))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved)] })] }) })] }), _jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secModel }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel)) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(data().cost)) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(T.provider, data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate) }) })] })] }), _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.distTitle }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle)) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distSys, fmt(data().dist.system), T.tok) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distUser, fmt(data().dist.user), T.tok) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distAgent, fmt(data().dist.agent), T.tok) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distTool, fmt(data().dist.toolCall), T.tok) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distRes, fmt(data().dist.toolResult), T.tok) }) }), _jsx("text", { fg: pal().text, children: justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok) })] })] })] }) })] }));
|
|
336
558
|
}
|
|
337
559
|
// ---------------------------------------------------------------------------
|
|
338
560
|
// Plugin entry
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-visual-cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@opencode-ai/plugin": "^1.14.50",
|
|
54
54
|
"@opencode-ai/sdk": "^1.14.50",
|
|
55
|
+
"tsx": "^4.22.3",
|
|
55
56
|
"typescript": "^5.8.0"
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/_version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// auto-generated
|
|
2
|
-
export const PLUGIN_VERSION="1.
|
|
2
|
+
export const PLUGIN_VERSION="1.2.3";
|
package/src/index.tsx
CHANGED
|
@@ -9,8 +9,15 @@ import type {
|
|
|
9
9
|
TuiPluginModule,
|
|
10
10
|
TuiThemeCurrent,
|
|
11
11
|
} from "@opencode-ai/plugin/tui"
|
|
12
|
-
import type { AssistantMessage, Message } from "@opencode-ai/sdk"
|
|
13
|
-
import {
|
|
12
|
+
import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
|
|
13
|
+
import type {
|
|
14
|
+
Part,
|
|
15
|
+
TextPart,
|
|
16
|
+
ToolPart,
|
|
17
|
+
FilePart,
|
|
18
|
+
ReasoningPart,
|
|
19
|
+
} from "@opencode-ai/sdk/v2"
|
|
20
|
+
import { createMemo, createSignal, onMount, onCleanup, Show } from "solid-js"
|
|
14
21
|
import { PLUGIN_VERSION } from "./_version"
|
|
15
22
|
|
|
16
23
|
// ---------------------------------------------------------------------------
|
|
@@ -52,7 +59,7 @@ function visualPadEnd(s: string, cols: number): string {
|
|
|
52
59
|
return pad > 0 ? s + " ".repeat(pad) : s
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
// ──
|
|
62
|
+
// ── language override (env: CACHE_TUI_LANG) ──
|
|
56
63
|
const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
|
|
57
64
|
|
|
58
65
|
// ── language ──────────────────────────────────────────────────────
|
|
@@ -83,6 +90,16 @@ const T = LANG_ZH ? {
|
|
|
83
90
|
writeRate: "写入",
|
|
84
91
|
noData: "等待缓存数据...",
|
|
85
92
|
tok: "tok",
|
|
93
|
+
distTitle: "估算 Token 分布",
|
|
94
|
+
distSys: "系统提示:",
|
|
95
|
+
distUser: "用户:",
|
|
96
|
+
distAgent: "Agent 指令:",
|
|
97
|
+
distTool: "Tool 调用:",
|
|
98
|
+
distRes: "Tool 结果:",
|
|
99
|
+
distTotal: "总计:",
|
|
100
|
+
distOut: "输出:",
|
|
101
|
+
secDetail: "明细",
|
|
102
|
+
secModel: "模型",
|
|
86
103
|
} as const : {
|
|
87
104
|
title: "Token Cache",
|
|
88
105
|
hit: "Hit",
|
|
@@ -102,6 +119,16 @@ const T = LANG_ZH ? {
|
|
|
102
119
|
writeRate: "write",
|
|
103
120
|
noData: "Waiting for cache data...",
|
|
104
121
|
tok: "tok",
|
|
122
|
+
distTitle: "Estimated Token Dist.",
|
|
123
|
+
distSys: "System:",
|
|
124
|
+
distUser: "User:",
|
|
125
|
+
distAgent: "Agent Instr:",
|
|
126
|
+
distTool: "Tool Call:",
|
|
127
|
+
distRes: "Tool Result:",
|
|
128
|
+
distTotal: "Total:",
|
|
129
|
+
distOut: "Output:",
|
|
130
|
+
secDetail: "Detail",
|
|
131
|
+
secModel: "Model",
|
|
105
132
|
} as const
|
|
106
133
|
|
|
107
134
|
// ── color helpers ────────────────────────────────────────────────
|
|
@@ -153,20 +180,30 @@ function desaturateTo(raw: unknown, maxSat: number, fallback: string): string {
|
|
|
153
180
|
// already muted — return as hex
|
|
154
181
|
return "#" + [c.r, c.g, c.b].map((v) => v.toString(16).padStart(2, "0")).join("")
|
|
155
182
|
}
|
|
156
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Binary search for the optimal grey-mix ratio α (0…1).
|
|
185
|
+
*
|
|
186
|
+
* 12 iterations → 1/2^12 ≈ 1/4096 resolution. The downstream RGB
|
|
187
|
+
* channels are only 0–255 (8 bit), so 8 iterations (1/256) would
|
|
188
|
+
* technically suffice; 12 is intentionally over-budget — the extra
|
|
189
|
+
* precision costs almost nothing and guarantees the saturation probe
|
|
190
|
+
* converges to within a fraction of an 8‑bit step, eliminating
|
|
191
|
+
* colour banding in edge cases.
|
|
192
|
+
*/
|
|
193
|
+
// BT.601 luma (perceptual brightness used as the grey anchor)
|
|
194
|
+
const luma = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
|
|
157
195
|
let lo = 0, hi = 1
|
|
158
196
|
for (let i = 0; i < 12; i++) {
|
|
159
197
|
const mid = (lo + hi) / 2
|
|
160
|
-
const nr = Math.round(c.r + (
|
|
161
|
-
const ng = Math.round(c.g + (
|
|
162
|
-
const nb = Math.round(c.b + (
|
|
198
|
+
const nr = Math.round(c.r + (luma - c.r) * mid)
|
|
199
|
+
const ng = Math.round(c.g + (luma - c.g) * mid)
|
|
200
|
+
const nb = Math.round(c.b + (luma - c.b) * mid)
|
|
163
201
|
if (saturation(nr, ng, nb) > maxSat) lo = mid
|
|
164
202
|
else hi = mid
|
|
165
203
|
}
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const nb = Math.round(c.b + (grey - c.b) * hi)
|
|
204
|
+
const nr = Math.round(c.r + (luma - c.r) * hi)
|
|
205
|
+
const ng = Math.round(c.g + (luma - c.g) * hi)
|
|
206
|
+
const nb = Math.round(c.b + (luma - c.b) * hi)
|
|
170
207
|
return "#" + [nr, ng, nb].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")
|
|
171
208
|
}
|
|
172
209
|
|
|
@@ -181,7 +218,18 @@ const FALLBACK = {
|
|
|
181
218
|
border: "#6B6B63",
|
|
182
219
|
} as const
|
|
183
220
|
|
|
184
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Desaturation ceiling for the Morandi-style palette.
|
|
223
|
+
*
|
|
224
|
+
* Morandi colours float around 0.15–0.30 saturation in HSL space.
|
|
225
|
+
* 0.28 sits near the upper end of that range: it strips the aggressive
|
|
226
|
+
* punch from high-saturation themes (Dracula, Solarized …) while
|
|
227
|
+
* preserving enough colour identity that green / orange / red hit-rate
|
|
228
|
+
* coding stays distinguishable.
|
|
229
|
+
*
|
|
230
|
+
* Lower → more grey, harder to tell colours apart.
|
|
231
|
+
* Higher → bright themes bleed through and defeat the muted look.
|
|
232
|
+
*/
|
|
185
233
|
const MAX_SAT = 0.28
|
|
186
234
|
|
|
187
235
|
function progressBar(percent: number, width: number): string {
|
|
@@ -207,6 +255,52 @@ function fmtCost(n: number): string {
|
|
|
207
255
|
return "$" + n.toFixed(4)
|
|
208
256
|
}
|
|
209
257
|
|
|
258
|
+
// ── token estimation ──
|
|
259
|
+
// Character-based BPE approximation. Default ratios (~4 ASCII or ~1.5 CJK
|
|
260
|
+
// chars per token) work well for natural language but systematically
|
|
261
|
+
// under-count tokens in JSON and source code where every punctuation mark
|
|
262
|
+
// tends to be its own token. Detect these cases and tighten the ratio.
|
|
263
|
+
// See: GPT-4 / Claude tokenizer behaviour with structured text.
|
|
264
|
+
|
|
265
|
+
function estimateTokens(text: string): number {
|
|
266
|
+
if (!text || text.length === 0) return 0
|
|
267
|
+
let ascii = 0
|
|
268
|
+
let cjk = 0
|
|
269
|
+
for (const c of text) {
|
|
270
|
+
const code = c.codePointAt(0) ?? 0
|
|
271
|
+
if (code >= 0x4E00 && code <= 0x9FFF) cjk++ // CJK Unified
|
|
272
|
+
else if (code >= 0x3040 && code <= 0x30FF) cjk++ // Hiragana/Katakana
|
|
273
|
+
else if (code >= 0xAC00 && code <= 0xD7A3) cjk++ // Hangul
|
|
274
|
+
else if (code >= 0x1100 && code <= 0x11FF) cjk++ // Hangul Jamo
|
|
275
|
+
else if (code >= 0x2E80 && code <= 0x2EFF) cjk++ // CJK Radicals
|
|
276
|
+
else ascii++
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Tighten the ASCII ratio for structured content where punctuation is
|
|
280
|
+
// token-dense. JSON key-value patterns and code keywords are strong
|
|
281
|
+
// signals that the default 4:1 ratio will materially under-estimate.
|
|
282
|
+
const trimmed = text.trimStart()
|
|
283
|
+
const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("["))
|
|
284
|
+
&& /"[^"]+"\s*:/.test(text)
|
|
285
|
+
const codeLike = !jsonLike
|
|
286
|
+
&& /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
|
|
287
|
+
|
|
288
|
+
const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4
|
|
289
|
+
return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface TokenDist {
|
|
293
|
+
system: number // UserMessage.system
|
|
294
|
+
user: number // user message text/file parts
|
|
295
|
+
agent: number // SubtaskPart.prompt + ReasoningPart.text
|
|
296
|
+
toolCall: number // ToolPart.input (actual tool params)
|
|
297
|
+
toolResult: number // ToolPart completed output / error
|
|
298
|
+
output: number // AssistantMessage.tokens.output (fallback)
|
|
299
|
+
apiOutput: number // StepFinishPart.tokens.output (API exact, preferred)
|
|
300
|
+
apiInput: number // StepFinishPart.tokens.input (API exact total context)
|
|
301
|
+
stepCost: number
|
|
302
|
+
}
|
|
303
|
+
|
|
210
304
|
// ---------------------------------------------------------------------------
|
|
211
305
|
// Sidebar component
|
|
212
306
|
// ---------------------------------------------------------------------------
|
|
@@ -232,6 +326,9 @@ function TokenCachePanel(props: {
|
|
|
232
326
|
}): JSX.Element {
|
|
233
327
|
const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
|
|
234
328
|
const [open, setOpen] = createSignal(true)
|
|
329
|
+
const [detailOpen, setDetailOpen] = createSignal(true)
|
|
330
|
+
const [modelOpen, setModelOpen] = createSignal(true)
|
|
331
|
+
const [distOpen, setDistOpen] = createSignal(false)
|
|
235
332
|
let boxEl: any
|
|
236
333
|
|
|
237
334
|
// ── scan session messages reactively ──
|
|
@@ -295,8 +392,8 @@ function TokenCachePanel(props: {
|
|
|
295
392
|
// `input` from the API represents fresh (non-cached) tokens.
|
|
296
393
|
const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
|
|
297
394
|
// Total context = fresh + cache.read.
|
|
298
|
-
const
|
|
299
|
-
const sessionHitRate =
|
|
395
|
+
const freshTotal = input + read
|
|
396
|
+
const sessionHitRate = freshTotal > 0 ? (read / freshTotal) * 100 : 0
|
|
300
397
|
const model = mid.split("/").pop() ?? mid
|
|
301
398
|
const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
|
|
302
399
|
|
|
@@ -308,6 +405,101 @@ function TokenCachePanel(props: {
|
|
|
308
405
|
|
|
309
406
|
const providerName = pid || ""
|
|
310
407
|
|
|
408
|
+
// ── token distribution (in-process via api.state.part) ──
|
|
409
|
+
// Wrapped in try-catch so a part fetching failure never crashes the panel.
|
|
410
|
+
let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
|
|
411
|
+
let hasDistData = false
|
|
412
|
+
try {
|
|
413
|
+
partVersion() // track part changes for reactivity
|
|
414
|
+
|
|
415
|
+
dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
|
|
416
|
+
|
|
417
|
+
// Read agent system prompt once before the message loop. Reading it
|
|
418
|
+
// inside the per-user-message branch risks transient unavailability
|
|
419
|
+
// (api.state.config not yet resolved during streaming) silently
|
|
420
|
+
// resetting a previously-computed value and causing display flicker.
|
|
421
|
+
try {
|
|
422
|
+
const session = props.api.state.session.get(props.sessionId)
|
|
423
|
+
const cfg = props.api.state.config as Record<string, unknown>
|
|
424
|
+
const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
|
|
425
|
+
const agents = cfg?.agent as Record<string, unknown> | undefined
|
|
426
|
+
const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
|
|
427
|
+
const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
|
|
428
|
+
if (sysPrompt) dist.system = estimateTokens(sysPrompt)
|
|
429
|
+
} catch {}
|
|
430
|
+
|
|
431
|
+
for (const msg of msgs) {
|
|
432
|
+
if (msg.role === "user") {
|
|
433
|
+
const um = msg as UserMessage
|
|
434
|
+
if (um.system) dist.system += estimateTokens(um.system)
|
|
435
|
+
let parts: readonly Part[] = []
|
|
436
|
+
try { parts = props.api.state.part(msg.id) } catch {}
|
|
437
|
+
for (const p of parts) {
|
|
438
|
+
if (p.type === "text" && !(p as unknown as Record<string, unknown>).synthetic && !(p as unknown as Record<string, unknown>).ignored) {
|
|
439
|
+
dist.user += estimateTokens((p as unknown as TextPart).text)
|
|
440
|
+
} else if (p.type === "file") {
|
|
441
|
+
const fp = p as unknown as FilePart
|
|
442
|
+
if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else if (msg.role === "assistant") {
|
|
446
|
+
const am = msg as AssistantMessage
|
|
447
|
+
dist.output += num(am.tokens?.output)
|
|
448
|
+
|
|
449
|
+
let parts: readonly Part[] = []
|
|
450
|
+
try { parts = props.api.state.part(msg.id) } catch {}
|
|
451
|
+
for (const p of parts) {
|
|
452
|
+
if (p.type === "tool") {
|
|
453
|
+
const tp = p as unknown as ToolPart
|
|
454
|
+
// Tool call input (params)
|
|
455
|
+
let rawInput = ""
|
|
456
|
+
try {
|
|
457
|
+
rawInput = (tp.state as unknown as { raw?: string }).raw ?? JSON.stringify(tp.state.input)
|
|
458
|
+
} catch { try { rawInput = JSON.stringify(tp.state) } catch {} }
|
|
459
|
+
if (rawInput) dist.toolCall += estimateTokens(rawInput)
|
|
460
|
+
// Tool result output
|
|
461
|
+
if (tp.state.status === "completed") {
|
|
462
|
+
const completed = tp.state as unknown as { output: string }
|
|
463
|
+
if (completed.output) dist.toolResult += estimateTokens(completed.output)
|
|
464
|
+
} else if (tp.state.status === "error") {
|
|
465
|
+
const errored = tp.state as unknown as { error: string }
|
|
466
|
+
if (errored.error) dist.toolResult += estimateTokens(errored.error)
|
|
467
|
+
}
|
|
468
|
+
} else if (p.type === "reasoning") {
|
|
469
|
+
dist.agent += estimateTokens((p as unknown as ReasoningPart).text)
|
|
470
|
+
} else if (p.type === "subtask") {
|
|
471
|
+
const sub = p as unknown as { prompt: string; description: string }
|
|
472
|
+
dist.agent += estimateTokens(sub.prompt || sub.description || "")
|
|
473
|
+
} else if (p.type === "step-finish") {
|
|
474
|
+
// StepFinishPart carries API-exact per-call token counts.
|
|
475
|
+
// Sum across all step-finish parts (one per API call in tool loops).
|
|
476
|
+
const sf = p as unknown as { tokens?: { input?: number; output?: number } }
|
|
477
|
+
dist.apiInput += sf.tokens?.input ?? 0
|
|
478
|
+
dist.apiOutput += sf.tokens?.output ?? 0
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult
|
|
485
|
+
const apiTotalInput = dist.apiInput
|
|
486
|
+
// Use API output if available (StepFinishPart is more accurate than AssistantMessage.tokens)
|
|
487
|
+
const finalOutput = dist.apiOutput > 0 ? dist.apiOutput : dist.output
|
|
488
|
+
|
|
489
|
+
// Gap inference: the SDK does not expose per-part token counts, so any
|
|
490
|
+
// API-exact input total that exceeds the locally-estimated sum is attributed
|
|
491
|
+
// to system prompt / agent config / tool-definition overhead. Add it to the
|
|
492
|
+
// system bucket rather than replacing the local estimate.
|
|
493
|
+
const overhead = Math.max(0, apiTotalInput - totalInput)
|
|
494
|
+
if (overhead >= 50) {
|
|
495
|
+
dist.system += overhead
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
hasDistData = totalInput > 0 || finalOutput > 0 || apiTotalInput > 0
|
|
499
|
+
} catch {
|
|
500
|
+
// Graceful degradation — dist stays at zeroes
|
|
501
|
+
}
|
|
502
|
+
|
|
311
503
|
return {
|
|
312
504
|
hitRate, read, write, freshInput: input, output,
|
|
313
505
|
cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
|
|
@@ -315,9 +507,37 @@ function TokenCachePanel(props: {
|
|
|
315
507
|
trend, hasTrendData,
|
|
316
508
|
providerName,
|
|
317
509
|
sessionHitRate,
|
|
510
|
+
dist, hasDistData,
|
|
318
511
|
}
|
|
319
512
|
})
|
|
320
513
|
|
|
514
|
+
// ── token distribution (in-process via api.state.part) ──
|
|
515
|
+
const [partVersion, setPartVersion] = createSignal(0)
|
|
516
|
+
|
|
517
|
+
// Persist fold state to api.kv
|
|
518
|
+
const KV_PREFIX = "cache_panel"
|
|
519
|
+
const persistFold = (key: string, val: boolean) => {
|
|
520
|
+
try { props.api.kv.set(`${KV_PREFIX}.${key}`, val) } catch {}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
onMount(() => {
|
|
524
|
+
// Restore fold state from persisted storage
|
|
525
|
+
try {
|
|
526
|
+
setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, true)))
|
|
527
|
+
setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
|
|
528
|
+
setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
|
|
529
|
+
setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
|
|
530
|
+
} catch {}
|
|
531
|
+
|
|
532
|
+
const unsubPart = props.api.event.on("message.part.updated", () => {
|
|
533
|
+
setPartVersion((v) => v + 1)
|
|
534
|
+
})
|
|
535
|
+
const unsubMsg = props.api.event.on("message.updated", () => {
|
|
536
|
+
setPartVersion((v) => v + 1)
|
|
537
|
+
})
|
|
538
|
+
onCleanup(() => { unsubPart(); unsubMsg() })
|
|
539
|
+
})
|
|
540
|
+
|
|
321
541
|
// ── colours ──
|
|
322
542
|
// Pull from the current theme, auto-desaturate if too punchy,
|
|
323
543
|
// fall back to Morandi when a key is missing from the theme.
|
|
@@ -353,7 +573,7 @@ function TokenCachePanel(props: {
|
|
|
353
573
|
return Math.max(3, panelWidth() - overhead)
|
|
354
574
|
})
|
|
355
575
|
const bar = createMemo(() => progressBar(data().hitRate, barW()))
|
|
356
|
-
const pct = createMemo(() => data().hitRate.toFixed(1) + "%")
|
|
576
|
+
const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
|
|
357
577
|
|
|
358
578
|
// left-align label, right-align value — auto-fill space between
|
|
359
579
|
const justify = (label: string, value: string, unit = ""): string => {
|
|
@@ -381,7 +601,7 @@ function TokenCachePanel(props: {
|
|
|
381
601
|
}}
|
|
382
602
|
>
|
|
383
603
|
{/* collapsible header */}
|
|
384
|
-
<text onMouseUp={() => setOpen((o) => !o)}>
|
|
604
|
+
<text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
|
|
385
605
|
<span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
|
|
386
606
|
<span style={{ fg: pal().primary }}>
|
|
387
607
|
<b>{T.title}</b>
|
|
@@ -434,70 +654,115 @@ function TokenCachePanel(props: {
|
|
|
434
654
|
|
|
435
655
|
{/* session cumulative hit rate */}
|
|
436
656
|
<text fg={pal().muted}>
|
|
437
|
-
{justify(T.totalHit, data().sessionHitRate.toFixed(1) + "%")}
|
|
657
|
+
{justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
|
|
438
658
|
</text>
|
|
439
659
|
|
|
440
|
-
{/*
|
|
441
|
-
<
|
|
660
|
+
{/* ── detail section (collapsible, default open) ── */}
|
|
661
|
+
<text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
|
|
662
|
+
<span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
663
|
+
<span style={{ fg: pal().primary }}><b>{T.secDetail}</b></span>
|
|
664
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail))}</span>
|
|
665
|
+
</text>
|
|
666
|
+
|
|
667
|
+
<Show when={detailOpen()}>
|
|
668
|
+
<Show when={data().read > 0}>
|
|
669
|
+
<text fg={pal().muted}>
|
|
670
|
+
{justify(T.read, fmt(data().read), T.tok)}
|
|
671
|
+
</text>
|
|
672
|
+
</Show>
|
|
673
|
+
<Show when={data().write > 0}>
|
|
674
|
+
<text fg={pal().muted}>
|
|
675
|
+
{justify(T.write, fmt(data().write), T.tok)}
|
|
676
|
+
</text>
|
|
677
|
+
</Show>
|
|
442
678
|
<text fg={pal().muted}>
|
|
443
|
-
{justify(T.
|
|
679
|
+
{justify(T.miss, fmt(data().freshInput), T.tok)}
|
|
444
680
|
</text>
|
|
445
|
-
</Show>
|
|
446
|
-
<Show when={data().write > 0}>
|
|
447
681
|
<text fg={pal().muted}>
|
|
448
|
-
{justify(T.
|
|
682
|
+
{justify(T.out, fmt(data().output), T.tok)}
|
|
449
683
|
</text>
|
|
684
|
+
<Show when={data().saved > 0}>
|
|
685
|
+
<text>
|
|
686
|
+
<span style={{ fg: pal().muted }}>{T.saved}</span>
|
|
687
|
+
<span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved))))}</span>
|
|
688
|
+
<span style={{ fg: pal().success }}>~{fmtCost(data().saved)}</span>
|
|
689
|
+
</text>
|
|
690
|
+
</Show>
|
|
450
691
|
</Show>
|
|
451
|
-
<text fg={pal().muted}>
|
|
452
|
-
{justify(T.miss, fmt(data().freshInput), T.tok)}
|
|
453
|
-
</text>
|
|
454
|
-
<text fg={pal().muted}>
|
|
455
|
-
{justify(T.out, fmt(data().output), T.tok)}
|
|
456
|
-
</text>
|
|
457
|
-
|
|
458
|
-
<text fg={pal().muted}>{sep()}</text>
|
|
459
692
|
|
|
460
|
-
{/*
|
|
461
|
-
<text
|
|
462
|
-
{
|
|
463
|
-
|
|
693
|
+
{/* ── model section (collapsible, default open) ── */}
|
|
694
|
+
{<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
|
|
695
|
+
<span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
696
|
+
<span style={{ fg: pal().primary }}><b>{T.secModel}</b></span>
|
|
697
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel))}</span>
|
|
698
|
+
</text>}
|
|
464
699
|
|
|
465
|
-
{
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
<span style={{ fg: pal().muted }}>{T.saved}</span>
|
|
469
|
-
<span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved))))}</span>
|
|
470
|
-
<span style={{ fg: pal().success }}>~{fmtCost(data().saved)}</span>
|
|
700
|
+
<Show when={modelOpen()}>
|
|
701
|
+
<text fg={pal().text}>
|
|
702
|
+
{justify(T.cost, fmtCost(data().cost))}
|
|
471
703
|
</text>
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
704
|
+
<Show when={data().providerName}>
|
|
705
|
+
<text fg={pal().muted}>
|
|
706
|
+
{justify(T.provider, data().providerName)}
|
|
707
|
+
</text>
|
|
708
|
+
</Show>
|
|
476
709
|
<text fg={pal().muted}>
|
|
477
|
-
{justify(T.
|
|
710
|
+
{justify(T.model, data().model)}
|
|
478
711
|
</text>
|
|
712
|
+
<Show when={data().hasPricing}>
|
|
713
|
+
<text fg={pal().muted}>
|
|
714
|
+
{justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate)}
|
|
715
|
+
</text>
|
|
716
|
+
<Show when={data().cacheReadRate > 0}>
|
|
717
|
+
<text fg={pal().muted}>
|
|
718
|
+
{justify("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
|
|
719
|
+
</text>
|
|
720
|
+
</Show>
|
|
721
|
+
<Show when={data().cacheWriteRate > 0}>
|
|
722
|
+
<text fg={pal().muted}>
|
|
723
|
+
{justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate)}
|
|
724
|
+
</text>
|
|
725
|
+
</Show>
|
|
726
|
+
</Show>
|
|
479
727
|
</Show>
|
|
480
728
|
|
|
481
|
-
{/*
|
|
482
|
-
<
|
|
483
|
-
{
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
</text>
|
|
491
|
-
<Show when={data().cacheReadRate > 0}>
|
|
729
|
+
{/* ── token distribution (collapsible, default closed) ── */}
|
|
730
|
+
<Show when={data().hasDistData}>
|
|
731
|
+
{<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
|
|
732
|
+
<span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
|
|
733
|
+
<span style={{ fg: pal().primary }}><b>{T.distTitle}</b></span>
|
|
734
|
+
<span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle))}</span>
|
|
735
|
+
</text>}
|
|
736
|
+
<Show when={distOpen()}>
|
|
737
|
+
<Show when={data().dist.system > 0}>
|
|
492
738
|
<text fg={pal().muted}>
|
|
493
|
-
{justify(
|
|
739
|
+
{justify(T.distSys, fmt(data().dist.system), T.tok)}
|
|
494
740
|
</text>
|
|
495
741
|
</Show>
|
|
496
|
-
<Show when={data().
|
|
742
|
+
<Show when={data().dist.user > 0}>
|
|
497
743
|
<text fg={pal().muted}>
|
|
498
|
-
{justify(
|
|
744
|
+
{justify(T.distUser, fmt(data().dist.user), T.tok)}
|
|
499
745
|
</text>
|
|
500
746
|
</Show>
|
|
747
|
+
<Show when={data().dist.agent > 0}>
|
|
748
|
+
<text fg={pal().muted}>
|
|
749
|
+
{justify(T.distAgent, fmt(data().dist.agent), T.tok)}
|
|
750
|
+
</text>
|
|
751
|
+
</Show>
|
|
752
|
+
<Show when={data().dist.toolCall > 0}>
|
|
753
|
+
<text fg={pal().muted}>
|
|
754
|
+
{justify(T.distTool, fmt(data().dist.toolCall), T.tok)}
|
|
755
|
+
</text>
|
|
756
|
+
</Show>
|
|
757
|
+
<Show when={data().dist.toolResult > 0}>
|
|
758
|
+
<text fg={pal().muted}>
|
|
759
|
+
{justify(T.distRes, fmt(data().dist.toolResult), T.tok)}
|
|
760
|
+
</text>
|
|
761
|
+
</Show>
|
|
762
|
+
<text fg={pal().text}>
|
|
763
|
+
{justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok)}
|
|
764
|
+
</text>
|
|
765
|
+
</Show>
|
|
501
766
|
</Show>
|
|
502
767
|
</Show>
|
|
503
768
|
</Show>
|