opencode-visual-cache 1.0.7 → 1.1.2

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.
Files changed (3) hide show
  1. package/dist/index.js +37 -15
  2. package/package.json +1 -1
  3. package/src/index.tsx +115 -69
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
2
2
  import { createMemo, createSignal, Show } from "solid-js";
3
3
  // ── terminal-width helpers ────────────────────────────────────────
4
4
  // CJK characters occupy 2 terminal columns; padEnd/padStart count
@@ -50,6 +50,7 @@ const LANG_ZH = DEBUG_LANG
50
50
  const T = LANG_ZH ? {
51
51
  title: "缓存统计",
52
52
  hit: "命中率",
53
+ totalHit: "总命中:",
53
54
  read: "缓存读:",
54
55
  write: "缓存写:",
55
56
  miss: "未命中:",
@@ -62,10 +63,13 @@ const T = LANG_ZH ? {
62
63
  hitFolded: "命中",
63
64
  inputRate: "输入",
64
65
  cacheRate: "缓存",
66
+ writeRate: "写入",
67
+ noData: "等待缓存数据...",
65
68
  tok: "tok",
66
69
  } : {
67
70
  title: "Token Cache",
68
71
  hit: "Hit",
72
+ totalHit: "Total Hit:",
69
73
  read: "Read:",
70
74
  write: "Write:",
71
75
  miss: "Miss:",
@@ -78,6 +82,8 @@ const T = LANG_ZH ? {
78
82
  hitFolded: "hit",
79
83
  inputRate: "in",
80
84
  cacheRate: "cache",
85
+ writeRate: "write",
86
+ noData: "Waiting for cache data...",
81
87
  tok: "tok",
82
88
  };
83
89
  // ── color helpers ────────────────────────────────────────────────
@@ -188,9 +194,19 @@ const MIN_PANEL_WIDTH = 20;
188
194
  const DEFAULT_PANEL_WIDTH = 26;
189
195
  /** Horizontal space eaten by border (1+1) + padding (2+2). */
190
196
  const GUTTER = 2 + 4;
197
+ /** ── layout measurement constants (visual columns) ── */
198
+ const LABEL_GAP = 1; // label(如 "Hit")后面的空格
199
+ const BAR_BRACKETS = 2; // "[" + "]" 包围进度条
200
+ const BAR_GAP = 1; // "]" 后面的空格
201
+ const PCT_FIXED_WIDTH = 5; // "XX.X%" 固定 5 字符宽度
202
+ const HEADER_PREFIX = 2; // 折叠态标题行:▶/▼ 图标 + 后面的空格
203
+ const UNIT_GAP = 1; // 计量单位前的空格(如 "tok")
204
+ /** Current plugin version — bump alongside npm version. */
205
+ const PLUGIN_VERSION = '1.1.1';
191
206
  function TokenCachePanel(props) {
192
207
  const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH);
193
208
  const [open, setOpen] = createSignal(true);
209
+ let boxEl;
194
210
  // ── scan session messages reactively ──
195
211
  // SolidJS createMemo re-evaluates whenever the underlying
196
212
  // api.state.session state changes — no event listener needed.
@@ -210,6 +226,8 @@ function TokenCachePanel(props) {
210
226
  if (msg.role !== "assistant")
211
227
  continue;
212
228
  const t = msg.tokens;
229
+ if (!t)
230
+ continue;
213
231
  const msgInputTokens = num(t.input) + num(t.cache?.read);
214
232
  const msgReadTokens = num(t.cache?.read);
215
233
  if (msgInputTokens > 0) {
@@ -230,6 +248,7 @@ function TokenCachePanel(props) {
230
248
  let saved = 0;
231
249
  let inputRate = 0;
232
250
  let cacheReadRate = 0;
251
+ let cacheWriteRate = 0;
233
252
  if (read > 0 && pid && mid) {
234
253
  for (const provider of props.api.state.provider) {
235
254
  if (provider.id !== pid)
@@ -239,6 +258,7 @@ function TokenCachePanel(props) {
239
258
  continue;
240
259
  inputRate = num(model.cost.input);
241
260
  cacheReadRate = num(model.cost.cache?.read);
261
+ cacheWriteRate = num(model.cost.cache?.write);
242
262
  const diff = inputRate - cacheReadRate;
243
263
  if (diff > 0)
244
264
  saved = (read * diff) / 1_000_000;
@@ -246,11 +266,12 @@ function TokenCachePanel(props) {
246
266
  }
247
267
  }
248
268
  // `input` from the API represents fresh (non-cached) tokens.
269
+ const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0;
249
270
  // Total context = fresh + cache.read.
250
271
  const totalInput = input + read;
251
- const hitRate = totalInput > 0 ? (read / totalInput) * 100 : 0;
272
+ const sessionHitRate = totalInput > 0 ? (read / totalInput) * 100 : 0;
252
273
  const model = mid.split("/").pop() ?? mid;
253
- const hasPricing = inputRate > 0 || cacheReadRate > 0;
274
+ const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
254
275
  let trend = 0;
255
276
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0;
256
277
  if (hasTrendData) {
@@ -259,13 +280,13 @@ function TokenCachePanel(props) {
259
280
  const providerName = pid || "";
260
281
  return {
261
282
  hitRate, read, write, freshInput: input, output,
262
- cost, saved, model, inputRate, cacheReadRate, hasPricing,
283
+ cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
263
284
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
264
285
  trend, hasTrendData,
265
286
  providerName,
287
+ sessionHitRate,
266
288
  };
267
289
  });
268
- const d = () => data();
269
290
  // ── colours ──
270
291
  // Pull from the current theme, auto-desaturate if too punchy,
271
292
  // fall back to Morandi when a key is missing from the theme.
@@ -283,7 +304,7 @@ function TokenCachePanel(props) {
283
304
  };
284
305
  });
285
306
  const hitColor = createMemo(() => {
286
- const r = d().hitRate;
307
+ const r = data().hitRate;
287
308
  if (r >= 85)
288
309
  return pal().success;
289
310
  if (r >= 70)
@@ -295,23 +316,24 @@ function TokenCachePanel(props) {
295
316
  return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "");
296
317
  }
297
318
  const barW = createMemo(() => {
298
- const trendSpace = d().hasTrendData ? 1 + visualWidth(trendLabel(d().trend)) : 0;
299
- const overhead = visualWidth(T.hit) + 1 + 2 + 1 + /*pct*/ 5 + trendSpace + GUTTER;
319
+ const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
320
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER;
300
321
  return Math.max(3, panelWidth() - overhead);
301
322
  });
302
- const bar = createMemo(() => progressBar(d().hitRate, barW()));
303
- const pct = createMemo(() => d().hitRate.toFixed(1) + "%");
323
+ const bar = createMemo(() => progressBar(data().hitRate, barW()));
324
+ const pct = createMemo(() => data().hitRate.toFixed(1) + "%");
304
325
  // left-align label, right-align value — auto-fill space between
305
326
  const justify = (label, value, unit = "") => {
306
327
  const gauge = panelWidth() - GUTTER;
307
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + 1 : 0);
328
+ const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0);
308
329
  const gap = Math.max(1, gauge - used);
309
330
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
310
331
  };
311
- return (_jsx(Show, { when: d().hasData, children: _jsxs("box", { border: true, borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: 2, paddingRight: 2, flexDirection: "column", gap: 0, onSizeChange: function () {
312
- const w = Math.max(MIN_PANEL_WIDTH, this.width);
313
- setPanelWidth((prev) => (prev === w ? prev : w));
314
- }, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => !o), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.title }) }), _jsxs(Show, { when: !open(), children: [_jsxs(Show, { when: d().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - 2 - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(d().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] }), _jsxs("span", { style: { fg: d().trend !== 0 ? (d().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(d().trend)] })] }), _jsxs(Show, { when: !d().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - 2 - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] })] }), _jsxs(Show, { when: open(), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [T.hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: d().hasTrendData, children: _jsxs("span", { style: { fg: d().trend !== 0 ? (d().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(d().trend)] }) })] }), _jsx(Show, { when: d().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.read, fmt(d().read), T.tok) }) }), _jsx(Show, { when: d().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(d().write), T.tok) }) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(d().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(d().output), T.tok) }), _jsx("text", { fg: pal().muted, children: sep() }), _jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(d().cost)) }), _jsx(Show, { when: d().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: T.saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(d().saved)))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(d().saved)] })] }) }), _jsx(Show, { when: d().providerName, children: _jsx("text", { fg: pal().muted, children: justify(T.provider, d().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, d().model) }), _jsxs(Show, { when: d().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, "$" + d().inputRate.toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: d().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", "$" + d().cacheReadRate.toFixed(2) + "/M " + T.cacheRate) }) })] })] })] }) }));
332
+ return (_jsxs("box", { border: true, borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: 2, paddingRight: 2, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
333
+ // 通过 ref 获取 Renderable 实例宽度
334
+ const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width) : DEFAULT_PANEL_WIDTH;
335
+ setPanelWidth((prev) => (prev === w ? prev : w));
336
+ }, 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) }) })] })] }) })] }));
315
337
  }
316
338
  // ---------------------------------------------------------------------------
317
339
  // Plugin entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.0.7",
3
+ "version": "1.1.2",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/index.tsx CHANGED
@@ -66,6 +66,7 @@ const LANG_ZH = DEBUG_LANG
66
66
  const T = LANG_ZH ? {
67
67
  title: "缓存统计",
68
68
  hit: "命中率",
69
+ totalHit: "总命中:",
69
70
  read: "缓存读:",
70
71
  write: "缓存写:",
71
72
  miss: "未命中:",
@@ -78,10 +79,13 @@ const T = LANG_ZH ? {
78
79
  hitFolded: "命中",
79
80
  inputRate: "输入",
80
81
  cacheRate: "缓存",
82
+ writeRate: "写入",
83
+ noData: "等待缓存数据...",
81
84
  tok: "tok",
82
85
  } as const : {
83
86
  title: "Token Cache",
84
87
  hit: "Hit",
88
+ totalHit: "Total Hit:",
85
89
  read: "Read:",
86
90
  write: "Write:",
87
91
  miss: "Miss:",
@@ -94,6 +98,8 @@ const T = LANG_ZH ? {
94
98
  hitFolded: "hit",
95
99
  inputRate: "in",
96
100
  cacheRate: "cache",
101
+ writeRate: "write",
102
+ noData: "Waiting for cache data...",
97
103
  tok: "tok",
98
104
  } as const
99
105
 
@@ -209,6 +215,17 @@ const DEFAULT_PANEL_WIDTH = 26
209
215
  /** Horizontal space eaten by border (1+1) + padding (2+2). */
210
216
  const GUTTER = 2 + 4
211
217
 
218
+ /** ── layout measurement constants (visual columns) ── */
219
+ const LABEL_GAP = 1 // label(如 "Hit")后面的空格
220
+ const BAR_BRACKETS = 2 // "[" + "]" 包围进度条
221
+ const BAR_GAP = 1 // "]" 后面的空格
222
+ const PCT_FIXED_WIDTH = 5 // "XX.X%" 固定 5 字符宽度
223
+ const HEADER_PREFIX = 2 // 折叠态标题行:▶/▼ 图标 + 后面的空格
224
+ const UNIT_GAP = 1 // 计量单位前的空格(如 "tok")
225
+
226
+ /** Current plugin version — bump alongside npm version. */
227
+ const PLUGIN_VERSION = '1.1.1'
228
+
212
229
  function TokenCachePanel(props: {
213
230
  theme: TuiThemeCurrent
214
231
  api: TuiPluginApi
@@ -216,6 +233,7 @@ function TokenCachePanel(props: {
216
233
  }): JSX.Element {
217
234
  const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
218
235
  const [open, setOpen] = createSignal(true)
236
+ let boxEl: any
219
237
 
220
238
  // ── scan session messages reactively ──
221
239
  // SolidJS createMemo re-evaluates whenever the underlying
@@ -238,6 +256,7 @@ function TokenCachePanel(props: {
238
256
  for (const msg of msgs) {
239
257
  if (msg.role !== "assistant") continue
240
258
  const t = (msg as AssistantMessage).tokens
259
+ if (!t) continue
241
260
  const msgInputTokens = num(t.input) + num(t.cache?.read)
242
261
  const msgReadTokens = num(t.cache?.read)
243
262
  if (msgInputTokens > 0) {
@@ -259,6 +278,7 @@ function TokenCachePanel(props: {
259
278
  let saved = 0
260
279
  let inputRate = 0
261
280
  let cacheReadRate = 0
281
+ let cacheWriteRate = 0
262
282
  if (read > 0 && pid && mid) {
263
283
  for (const provider of props.api.state.provider) {
264
284
  if (provider.id !== pid) continue
@@ -266,6 +286,7 @@ function TokenCachePanel(props: {
266
286
  if (!model?.cost) continue
267
287
  inputRate = num(model.cost.input)
268
288
  cacheReadRate = num(model.cost.cache?.read)
289
+ cacheWriteRate = num(model.cost.cache?.write)
269
290
  const diff = inputRate - cacheReadRate
270
291
  if (diff > 0) saved = (read * diff) / 1_000_000
271
292
  break
@@ -273,11 +294,12 @@ function TokenCachePanel(props: {
273
294
  }
274
295
 
275
296
  // `input` from the API represents fresh (non-cached) tokens.
297
+ const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
276
298
  // Total context = fresh + cache.read.
277
299
  const totalInput = input + read
278
- const hitRate = totalInput > 0 ? (read / totalInput) * 100 : 0
300
+ const sessionHitRate = totalInput > 0 ? (read / totalInput) * 100 : 0
279
301
  const model = mid.split("/").pop() ?? mid
280
- const hasPricing = inputRate > 0 || cacheReadRate > 0
302
+ const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
281
303
 
282
304
  let trend = 0
283
305
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
@@ -289,15 +311,14 @@ function TokenCachePanel(props: {
289
311
 
290
312
  return {
291
313
  hitRate, read, write, freshInput: input, output,
292
- cost, saved, model, inputRate, cacheReadRate, hasPricing,
314
+ cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
293
315
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
294
316
  trend, hasTrendData,
295
317
  providerName,
318
+ sessionHitRate,
296
319
  }
297
320
  })
298
321
 
299
- const d = () => data()
300
-
301
322
  // ── colours ──
302
323
  // Pull from the current theme, auto-desaturate if too punchy,
303
324
  // fall back to Morandi when a key is missing from the theme.
@@ -316,7 +337,7 @@ function TokenCachePanel(props: {
316
337
  })
317
338
 
318
339
  const hitColor = createMemo(() => {
319
- const r = d().hitRate
340
+ const r = data().hitRate
320
341
  if (r >= 85) return pal().success
321
342
  if (r >= 70) return pal().warning
322
343
  return pal().error
@@ -328,61 +349,76 @@ function TokenCachePanel(props: {
328
349
  }
329
350
 
330
351
  const barW = createMemo(() => {
331
- const trendSpace = d().hasTrendData ? 1 + visualWidth(trendLabel(d().trend)) : 0
332
- const overhead = visualWidth(T.hit) + 1 + 2 + 1 + /*pct*/5 + trendSpace + GUTTER
352
+ const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
353
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER
333
354
  return Math.max(3, panelWidth() - overhead)
334
355
  })
335
- const bar = createMemo(() => progressBar(d().hitRate, barW()))
336
- const pct = createMemo(() => d().hitRate.toFixed(1) + "%")
356
+ const bar = createMemo(() => progressBar(data().hitRate, barW()))
357
+ const pct = createMemo(() => data().hitRate.toFixed(1) + "%")
337
358
 
338
359
  // left-align label, right-align value — auto-fill space between
339
360
  const justify = (label: string, value: string, unit = ""): string => {
340
361
  const gauge = panelWidth() - GUTTER
341
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + 1 : 0)
362
+ const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
342
363
  const gap = Math.max(1, gauge - used)
343
364
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
344
365
  }
345
366
 
346
367
  return (
347
- <Show when={d().hasData}>
348
- <box
349
- border
350
- borderColor={pal().border}
351
- paddingTop={0}
352
- paddingBottom={0}
353
- paddingLeft={2}
354
- paddingRight={2}
355
- flexDirection="column"
356
- gap={0}
357
- onSizeChange={function () {
358
- const w = Math.max(MIN_PANEL_WIDTH, this.width)
359
- setPanelWidth((prev) => (prev === w ? prev : w))
360
- }}
361
- >
362
- {/* collapsible header */}
363
- <text onMouseUp={() => setOpen((o) => !o)}>
364
- <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
365
- <span style={{ fg: pal().primary }}><b>{T.title}</b></span>
366
- <Show when={!open()}>
367
- <Show when={d().hasTrendData}>
368
- <span>
369
- {" ".repeat(Math.max(1, panelWidth() - GUTTER - 2 - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(d().trend))))}
370
- </span>
371
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
372
- <span style={{ fg: d().trend !== 0 ? (d().trend > 0 ? pal().success : pal().error) : pal().text }}>
373
- {" "}{trendLabel(d().trend)}
374
- </span>
375
- </Show>
376
- <Show when={!d().hasTrendData}>
377
- <span>
378
- {" ".repeat(Math.max(1, panelWidth() - GUTTER - 2 - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
379
- </span>
380
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
368
+ <box
369
+ border
370
+ borderColor={pal().border}
371
+ paddingTop={0}
372
+ paddingBottom={0}
373
+ paddingLeft={2}
374
+ paddingRight={2}
375
+ flexDirection="column"
376
+ gap={0}
377
+ ref={boxEl}
378
+ onSizeChange={() => {
379
+ // 通过 ref 获取 Renderable 实例宽度
380
+ const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width) : DEFAULT_PANEL_WIDTH
381
+ setPanelWidth((prev) => (prev === w ? prev : w))
382
+ }}
383
+ >
384
+ {/* collapsible header */}
385
+ <text onMouseUp={() => setOpen((o) => !o)}>
386
+ <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
387
+ <span style={{ fg: pal().primary }}>
388
+ <b>{T.title}</b>
389
+ <Show when={open()}>
390
+ <span style={{ fg: pal().muted }}> (v{PLUGIN_VERSION})</span>
381
391
  </Show>
392
+ </span>
393
+ <Show when={!open() && data().hasData}>
394
+ <Show when={data().hasTrendData}>
395
+ <span>
396
+ {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
397
+ </span>
398
+ <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
399
+ <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
400
+ {" "}{trendLabel(data().trend)}
401
+ </span>
382
402
  </Show>
383
- </text>
403
+ <Show when={!data().hasTrendData}>
404
+ <span>
405
+ {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
406
+ </span>
407
+ <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
408
+ </Show>
409
+ </Show>
410
+ </text>
384
411
 
385
- <Show when={open()}>
412
+ <Show when={open()}>
413
+ <Show when={data().hasData} fallback={
414
+ <>
415
+ <text fg={pal().muted}>{sep()}</text>
416
+ <text>
417
+ <span style={{ fg: pal().muted }}>{"> "}</span>
418
+ <span style={{ fg: pal().muted }}>{T.noData}</span>
419
+ </text>
420
+ </>
421
+ }>
386
422
  <text fg={pal().muted}>{sep()}</text>
387
423
 
388
424
  {/* hit rate + bar — inline to avoid box spacing */}
@@ -390,73 +426,83 @@ function TokenCachePanel(props: {
390
426
  <span style={{ fg: pal().text }}>{T.hit} </span>
391
427
  <span style={{ fg: hitColor() }}>[{bar()}] </span>
392
428
  <span style={{ fg: pal().text }}>{pct()}</span>
393
- <Show when={d().hasTrendData}>
394
- <span style={{ fg: d().trend !== 0 ? (d().trend > 0 ? pal().success : pal().error) : pal().text }}>
395
- {" "}{trendLabel(d().trend)}
429
+ <Show when={data().hasTrendData}>
430
+ <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
431
+ {" "}{trendLabel(data().trend)}
396
432
  </span>
397
433
  </Show>
398
434
  </text>
399
435
 
436
+ {/* session cumulative hit rate */}
437
+ <text fg={pal().muted}>
438
+ {justify(T.totalHit, data().sessionHitRate.toFixed(1) + "%")}
439
+ </text>
440
+
400
441
  {/* token breakdown */}
401
- <Show when={d().read > 0}>
442
+ <Show when={data().read > 0}>
402
443
  <text fg={pal().muted}>
403
- {justify(T.read, fmt(d().read), T.tok)}
444
+ {justify(T.read, fmt(data().read), T.tok)}
404
445
  </text>
405
446
  </Show>
406
- <Show when={d().write > 0}>
447
+ <Show when={data().write > 0}>
407
448
  <text fg={pal().muted}>
408
- {justify(T.write, fmt(d().write), T.tok)}
449
+ {justify(T.write, fmt(data().write), T.tok)}
409
450
  </text>
410
451
  </Show>
411
452
  <text fg={pal().muted}>
412
- {justify(T.miss, fmt(d().freshInput), T.tok)}
453
+ {justify(T.miss, fmt(data().freshInput), T.tok)}
413
454
  </text>
414
455
  <text fg={pal().muted}>
415
- {justify(T.out, fmt(d().output), T.tok)}
456
+ {justify(T.out, fmt(data().output), T.tok)}
416
457
  </text>
417
458
 
418
459
  <text fg={pal().muted}>{sep()}</text>
419
460
 
420
461
  {/* cost */}
421
462
  <text fg={pal().text}>
422
- {justify(T.cost, fmtCost(d().cost))}
463
+ {justify(T.cost, fmtCost(data().cost))}
423
464
  </text>
424
465
 
425
466
  {/* saved */}
426
- <Show when={d().saved > 0}>
467
+ <Show when={data().saved > 0}>
427
468
  <text>
428
469
  <span style={{ fg: pal().muted }}>{T.saved}</span>
429
- <span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(d().saved))))}</span>
430
- <span style={{ fg: pal().success }}>~{fmtCost(d().saved)}</span>
470
+ <span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved))))}</span>
471
+ <span style={{ fg: pal().success }}>~{fmtCost(data().saved)}</span>
431
472
  </text>
432
473
  </Show>
433
474
 
434
475
  {/* provider */}
435
- <Show when={d().providerName}>
476
+ <Show when={data().providerName}>
436
477
  <text fg={pal().muted}>
437
- {justify(T.provider, d().providerName)}
478
+ {justify(T.provider, data().providerName)}
438
479
  </text>
439
480
  </Show>
440
481
 
441
482
  {/* model */}
442
483
  <text fg={pal().muted}>
443
- {justify(T.model, d().model)}
484
+ {justify(T.model, data().model)}
444
485
  </text>
445
486
 
446
487
  {/* rates */}
447
- <Show when={d().hasPricing}>
488
+ <Show when={data().hasPricing}>
448
489
  <text fg={pal().muted}>
449
- {justify(T.rate, "$" + d().inputRate.toFixed(2) + "/M " + T.inputRate)}
490
+ {justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate)}
450
491
  </text>
451
- <Show when={d().cacheReadRate > 0}>
492
+ <Show when={data().cacheReadRate > 0}>
493
+ <text fg={pal().muted}>
494
+ {justify("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
495
+ </text>
496
+ </Show>
497
+ <Show when={data().cacheWriteRate > 0}>
452
498
  <text fg={pal().muted}>
453
- {justify("", "$" + d().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
499
+ {justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate)}
454
500
  </text>
455
501
  </Show>
456
502
  </Show>
457
503
  </Show>
458
- </box>
459
- </Show>
504
+ </Show>
505
+ </box>
460
506
  )
461
507
  }
462
508