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 CHANGED
@@ -5,10 +5,15 @@
5
5
  自适应主题色 · 自动低饱和设计语言 · 支持中/英双语
6
6
  </strong>
7
7
  <br>
8
+ <br>
9
+ 如果你觉得这个插件不错的话,可以帮我点点小星星 ⭐,谢谢!<br>
10
+ <br>
8
11
 
12
+ [![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=flat-square&logo=github)](https://github.com/Hotakus/opencode-visual-cache)
9
13
  [![Stars](https://img.shields.io/github/stars/Hotakus/opencode-visual-cache?style=flat-square)](https://github.com/Hotakus/opencode-visual-cache/stargazers)
10
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
- [![English](https://img.shields.io/badge/English-README-blue?style=flat-square)](README_EN.md)
14
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE)
15
+ [![English](https://img.shields.io/badge/English-README-blue?style=flat-square)](https://github.com/Hotakus/opencode-visual-cache/blob/master/README_EN.md)
16
+ ![NPM Version](https://img.shields.io/npm/v/opencode-visual-cache?style=flat-square)
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
+ [![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=flat-square&logo=github)](https://github.com/Hotakus/opencode-visual-cache)
9
13
  [![Stars](https://img.shields.io/github/stars/Hotakus/opencode-visual-cache?style=flat-square)](https://github.com/Hotakus/opencode-visual-cache/stargazers)
10
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
- [![中文](https://img.shields.io/badge/中文-README-red?style=flat-square)](README.md)
14
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE)
15
+ [![中文](https://img.shields.io/badge/中文-README-blue?style=flat-square)](https://github.com/Hotakus/opencode-visual-cache/blob/master/README.md)
16
+ ![NPM Version](https://img.shields.io/npm/v/opencode-visual-cache?style=flat-square)
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
- - **Language**: Auto-detects system locale, switches between Chinese and English
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
 
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.1.6";
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.1.6";
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
- // ── debug: set CACHE_TUI_LANG=en|zh to override auto-detection ──
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
- // binary search the right amount of grey to mix in
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 + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.r) * mid);
143
- const ng = Math.round(c.g + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.g) * mid);
144
- const nb = Math.round(c.b + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.b) * mid);
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 grey = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
151
- const nr = Math.round(c.r + (grey - c.r) * hi);
152
- const ng = Math.round(c.g + (grey - c.g) * hi);
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
- /** Morandi ceiling — colours above this saturation get pulled down. */
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 totalInput = input + read;
271
- const sessionHitRate = totalInput > 0 ? (read / totalInput) * 100 : 0;
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) + "%") }), _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("text", { fg: pal().muted, children: sep() }), _jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(data().cost)) }), _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)] })] }) }), _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) }) })] })] }) })] }));
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.1.6",
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.1.6";
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 { createMemo, createSignal, Show } from "solid-js"
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
- // ── debug: set CACHE_TUI_LANG=en|zh to override auto-detection ──
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
- // binary search the right amount of grey to mix in
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 + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.r) * mid)
161
- const ng = Math.round(c.g + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.g) * mid)
162
- const nb = Math.round(c.b + ((c.r * 0.299 + c.g * 0.587 + c.b * 0.114) - c.b) * mid)
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 grey = c.r * 0.299 + c.g * 0.587 + c.b * 0.114
167
- const nr = Math.round(c.r + (grey - c.r) * hi)
168
- const ng = Math.round(c.g + (grey - c.g) * hi)
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
- /** Morandi ceiling — colours above this saturation get pulled down. */
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 totalInput = input + read
299
- const sessionHitRate = totalInput > 0 ? (read / totalInput) * 100 : 0
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
- {/* token breakdown */}
441
- <Show when={data().read > 0}>
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.read, fmt(data().read), T.tok)}
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.write, fmt(data().write), T.tok)}
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
- {/* cost */}
461
- <text fg={pal().text}>
462
- {justify(T.cost, fmtCost(data().cost))}
463
- </text>
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
- {/* saved */}
466
- <Show when={data().saved > 0}>
467
- <text>
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
- </Show>
473
-
474
- {/* provider */}
475
- <Show when={data().providerName}>
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.provider, data().providerName)}
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
- {/* model */}
482
- <text fg={pal().muted}>
483
- {justify(T.model, data().model)}
484
- </text>
485
-
486
- {/* rates */}
487
- <Show when={data().hasPricing}>
488
- <text fg={pal().muted}>
489
- {justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate)}
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("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
739
+ {justify(T.distSys, fmt(data().dist.system), T.tok)}
494
740
  </text>
495
741
  </Show>
496
- <Show when={data().cacheWriteRate > 0}>
742
+ <Show when={data().dist.user > 0}>
497
743
  <text fg={pal().muted}>
498
- {justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate)}
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>