opencode-visual-cache 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,7 @@
42
42
 
43
43
  ### 方式一:OpenCode 命令安装(推荐)
44
44
 
45
- 在 OpenCode 中按 **`Shift + P`** 打开命令面板,搜索 **`install plugin`**,输入:
45
+ 在 OpenCode 中按 **`Ctrl + P`** 打开命令面板,搜索 **`install plugin`**,输入:
46
46
 
47
47
  ```
48
48
  opencode-visual-cache
package/README_EN.md CHANGED
@@ -43,7 +43,7 @@
43
43
 
44
44
  ### Option 1: OpenCode Command (recommended)
45
45
 
46
- Press **`Shift + P`** in OpenCode to open the command palette, search **`install plugin`**, then type:
46
+ Press **`Ctrl + P`** in OpenCode to open the command palette, search **`install plugin`**, then type:
47
47
 
48
48
  ```
49
49
  opencode-visual-cache
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,17 @@ 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")
191
204
  function TokenCachePanel(props) {
192
205
  const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH);
193
206
  const [open, setOpen] = createSignal(true);
207
+ let boxEl;
194
208
  // ── scan session messages reactively ──
195
209
  // SolidJS createMemo re-evaluates whenever the underlying
196
210
  // api.state.session state changes — no event listener needed.
@@ -210,6 +224,8 @@ function TokenCachePanel(props) {
210
224
  if (msg.role !== "assistant")
211
225
  continue;
212
226
  const t = msg.tokens;
227
+ if (!t)
228
+ continue;
213
229
  const msgInputTokens = num(t.input) + num(t.cache?.read);
214
230
  const msgReadTokens = num(t.cache?.read);
215
231
  if (msgInputTokens > 0) {
@@ -230,6 +246,7 @@ function TokenCachePanel(props) {
230
246
  let saved = 0;
231
247
  let inputRate = 0;
232
248
  let cacheReadRate = 0;
249
+ let cacheWriteRate = 0;
233
250
  if (read > 0 && pid && mid) {
234
251
  for (const provider of props.api.state.provider) {
235
252
  if (provider.id !== pid)
@@ -239,6 +256,7 @@ function TokenCachePanel(props) {
239
256
  continue;
240
257
  inputRate = num(model.cost.input);
241
258
  cacheReadRate = num(model.cost.cache?.read);
259
+ cacheWriteRate = num(model.cost.cache?.write);
242
260
  const diff = inputRate - cacheReadRate;
243
261
  if (diff > 0)
244
262
  saved = (read * diff) / 1_000_000;
@@ -246,11 +264,12 @@ function TokenCachePanel(props) {
246
264
  }
247
265
  }
248
266
  // `input` from the API represents fresh (non-cached) tokens.
267
+ const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0;
249
268
  // Total context = fresh + cache.read.
250
269
  const totalInput = input + read;
251
- const hitRate = totalInput > 0 ? (read / totalInput) * 100 : 0;
270
+ const sessionHitRate = totalInput > 0 ? (read / totalInput) * 100 : 0;
252
271
  const model = mid.split("/").pop() ?? mid;
253
- const hasPricing = inputRate > 0 || cacheReadRate > 0;
272
+ const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
254
273
  let trend = 0;
255
274
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0;
256
275
  if (hasTrendData) {
@@ -259,13 +278,13 @@ function TokenCachePanel(props) {
259
278
  const providerName = pid || "";
260
279
  return {
261
280
  hitRate, read, write, freshInput: input, output,
262
- cost, saved, model, inputRate, cacheReadRate, hasPricing,
281
+ cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
263
282
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
264
283
  trend, hasTrendData,
265
284
  providerName,
285
+ sessionHitRate,
266
286
  };
267
287
  });
268
- const d = () => data();
269
288
  // ── colours ──
270
289
  // Pull from the current theme, auto-desaturate if too punchy,
271
290
  // fall back to Morandi when a key is missing from the theme.
@@ -283,7 +302,7 @@ function TokenCachePanel(props) {
283
302
  };
284
303
  });
285
304
  const hitColor = createMemo(() => {
286
- const r = d().hitRate;
305
+ const r = data().hitRate;
287
306
  if (r >= 85)
288
307
  return pal().success;
289
308
  if (r >= 70)
@@ -295,23 +314,24 @@ function TokenCachePanel(props) {
295
314
  return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "");
296
315
  }
297
316
  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;
317
+ const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
318
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER;
300
319
  return Math.max(3, panelWidth() - overhead);
301
320
  });
302
- const bar = createMemo(() => progressBar(d().hitRate, barW()));
303
- const pct = createMemo(() => d().hitRate.toFixed(1) + "%");
321
+ const bar = createMemo(() => progressBar(data().hitRate, barW()));
322
+ const pct = createMemo(() => data().hitRate.toFixed(1) + "%");
304
323
  // left-align label, right-align value — auto-fill space between
305
324
  const justify = (label, value, unit = "") => {
306
325
  const gauge = panelWidth() - GUTTER;
307
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + 1 : 0);
326
+ const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0);
308
327
  const gap = Math.max(1, gauge - used);
309
328
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
310
329
  };
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) }) })] })] })] }) }));
330
+ return (_jsxs("box", { border: true, borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: 2, paddingRight: 2, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
331
+ // 通过 ref 获取 Renderable 实例宽度
332
+ const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width) : DEFAULT_PANEL_WIDTH;
333
+ setPanelWidth((prev) => (prev === w ? prev : w));
334
+ }, 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() && 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
335
  }
316
336
  // ---------------------------------------------------------------------------
317
337
  // Plugin entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
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,14 @@ 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
+
212
226
  function TokenCachePanel(props: {
213
227
  theme: TuiThemeCurrent
214
228
  api: TuiPluginApi
@@ -216,6 +230,7 @@ function TokenCachePanel(props: {
216
230
  }): JSX.Element {
217
231
  const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
218
232
  const [open, setOpen] = createSignal(true)
233
+ let boxEl: any
219
234
 
220
235
  // ── scan session messages reactively ──
221
236
  // SolidJS createMemo re-evaluates whenever the underlying
@@ -238,6 +253,7 @@ function TokenCachePanel(props: {
238
253
  for (const msg of msgs) {
239
254
  if (msg.role !== "assistant") continue
240
255
  const t = (msg as AssistantMessage).tokens
256
+ if (!t) continue
241
257
  const msgInputTokens = num(t.input) + num(t.cache?.read)
242
258
  const msgReadTokens = num(t.cache?.read)
243
259
  if (msgInputTokens > 0) {
@@ -259,6 +275,7 @@ function TokenCachePanel(props: {
259
275
  let saved = 0
260
276
  let inputRate = 0
261
277
  let cacheReadRate = 0
278
+ let cacheWriteRate = 0
262
279
  if (read > 0 && pid && mid) {
263
280
  for (const provider of props.api.state.provider) {
264
281
  if (provider.id !== pid) continue
@@ -266,6 +283,7 @@ function TokenCachePanel(props: {
266
283
  if (!model?.cost) continue
267
284
  inputRate = num(model.cost.input)
268
285
  cacheReadRate = num(model.cost.cache?.read)
286
+ cacheWriteRate = num(model.cost.cache?.write)
269
287
  const diff = inputRate - cacheReadRate
270
288
  if (diff > 0) saved = (read * diff) / 1_000_000
271
289
  break
@@ -273,11 +291,12 @@ function TokenCachePanel(props: {
273
291
  }
274
292
 
275
293
  // `input` from the API represents fresh (non-cached) tokens.
294
+ const hitRate = lastMsgHitRate >= 0 ? lastMsgHitRate : 0
276
295
  // Total context = fresh + cache.read.
277
296
  const totalInput = input + read
278
- const hitRate = totalInput > 0 ? (read / totalInput) * 100 : 0
297
+ const sessionHitRate = totalInput > 0 ? (read / totalInput) * 100 : 0
279
298
  const model = mid.split("/").pop() ?? mid
280
- const hasPricing = inputRate > 0 || cacheReadRate > 0
299
+ const hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0
281
300
 
282
301
  let trend = 0
283
302
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0
@@ -289,15 +308,14 @@ function TokenCachePanel(props: {
289
308
 
290
309
  return {
291
310
  hitRate, read, write, freshInput: input, output,
292
- cost, saved, model, inputRate, cacheReadRate, hasPricing,
311
+ cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
293
312
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
294
313
  trend, hasTrendData,
295
314
  providerName,
315
+ sessionHitRate,
296
316
  }
297
317
  })
298
318
 
299
- const d = () => data()
300
-
301
319
  // ── colours ──
302
320
  // Pull from the current theme, auto-desaturate if too punchy,
303
321
  // fall back to Morandi when a key is missing from the theme.
@@ -316,7 +334,7 @@ function TokenCachePanel(props: {
316
334
  })
317
335
 
318
336
  const hitColor = createMemo(() => {
319
- const r = d().hitRate
337
+ const r = data().hitRate
320
338
  if (r >= 85) return pal().success
321
339
  if (r >= 70) return pal().warning
322
340
  return pal().error
@@ -328,61 +346,71 @@ function TokenCachePanel(props: {
328
346
  }
329
347
 
330
348
  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
349
+ const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
350
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER
333
351
  return Math.max(3, panelWidth() - overhead)
334
352
  })
335
- const bar = createMemo(() => progressBar(d().hitRate, barW()))
336
- const pct = createMemo(() => d().hitRate.toFixed(1) + "%")
353
+ const bar = createMemo(() => progressBar(data().hitRate, barW()))
354
+ const pct = createMemo(() => data().hitRate.toFixed(1) + "%")
337
355
 
338
356
  // left-align label, right-align value — auto-fill space between
339
357
  const justify = (label: string, value: string, unit = ""): string => {
340
358
  const gauge = panelWidth() - GUTTER
341
- const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + 1 : 0)
359
+ const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
342
360
  const gap = Math.max(1, gauge - used)
343
361
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
344
362
  }
345
363
 
346
364
  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>
381
- </Show>
365
+ <box
366
+ border
367
+ borderColor={pal().border}
368
+ paddingTop={0}
369
+ paddingBottom={0}
370
+ paddingLeft={2}
371
+ paddingRight={2}
372
+ flexDirection="column"
373
+ gap={0}
374
+ ref={boxEl}
375
+ onSizeChange={() => {
376
+ // 通过 ref 获取 Renderable 实例宽度
377
+ const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width) : DEFAULT_PANEL_WIDTH
378
+ setPanelWidth((prev) => (prev === w ? prev : w))
379
+ }}
380
+ >
381
+ {/* collapsible header */}
382
+ <text onMouseUp={() => setOpen((o) => !o)}>
383
+ <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
384
+ <span style={{ fg: pal().primary }}><b>{T.title}</b></span>
385
+ <Show when={!open() && data().hasData}>
386
+ <Show when={data().hasTrendData}>
387
+ <span>
388
+ {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
389
+ </span>
390
+ <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
391
+ <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
392
+ {" "}{trendLabel(data().trend)}
393
+ </span>
382
394
  </Show>
383
- </text>
395
+ <Show when={!data().hasTrendData}>
396
+ <span>
397
+ {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
398
+ </span>
399
+ <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
400
+ </Show>
401
+ </Show>
402
+ </text>
384
403
 
385
- <Show when={open()}>
404
+ <Show when={open()}>
405
+ <Show when={data().hasData} fallback={
406
+ <>
407
+ <text fg={pal().muted}>{sep()}</text>
408
+ <text>
409
+ <span style={{ fg: pal().muted }}>{"> "}</span>
410
+ <span style={{ fg: pal().muted }}>{T.noData}</span>
411
+ </text>
412
+ </>
413
+ }>
386
414
  <text fg={pal().muted}>{sep()}</text>
387
415
 
388
416
  {/* hit rate + bar — inline to avoid box spacing */}
@@ -390,73 +418,83 @@ function TokenCachePanel(props: {
390
418
  <span style={{ fg: pal().text }}>{T.hit} </span>
391
419
  <span style={{ fg: hitColor() }}>[{bar()}] </span>
392
420
  <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)}
421
+ <Show when={data().hasTrendData}>
422
+ <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
423
+ {" "}{trendLabel(data().trend)}
396
424
  </span>
397
425
  </Show>
398
426
  </text>
399
427
 
428
+ {/* session cumulative hit rate */}
429
+ <text fg={pal().muted}>
430
+ {justify(T.totalHit, data().sessionHitRate.toFixed(1) + "%")}
431
+ </text>
432
+
400
433
  {/* token breakdown */}
401
- <Show when={d().read > 0}>
434
+ <Show when={data().read > 0}>
402
435
  <text fg={pal().muted}>
403
- {justify(T.read, fmt(d().read), T.tok)}
436
+ {justify(T.read, fmt(data().read), T.tok)}
404
437
  </text>
405
438
  </Show>
406
- <Show when={d().write > 0}>
439
+ <Show when={data().write > 0}>
407
440
  <text fg={pal().muted}>
408
- {justify(T.write, fmt(d().write), T.tok)}
441
+ {justify(T.write, fmt(data().write), T.tok)}
409
442
  </text>
410
443
  </Show>
411
444
  <text fg={pal().muted}>
412
- {justify(T.miss, fmt(d().freshInput), T.tok)}
445
+ {justify(T.miss, fmt(data().freshInput), T.tok)}
413
446
  </text>
414
447
  <text fg={pal().muted}>
415
- {justify(T.out, fmt(d().output), T.tok)}
448
+ {justify(T.out, fmt(data().output), T.tok)}
416
449
  </text>
417
450
 
418
451
  <text fg={pal().muted}>{sep()}</text>
419
452
 
420
453
  {/* cost */}
421
454
  <text fg={pal().text}>
422
- {justify(T.cost, fmtCost(d().cost))}
455
+ {justify(T.cost, fmtCost(data().cost))}
423
456
  </text>
424
457
 
425
458
  {/* saved */}
426
- <Show when={d().saved > 0}>
459
+ <Show when={data().saved > 0}>
427
460
  <text>
428
461
  <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>
462
+ <span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved))))}</span>
463
+ <span style={{ fg: pal().success }}>~{fmtCost(data().saved)}</span>
431
464
  </text>
432
465
  </Show>
433
466
 
434
467
  {/* provider */}
435
- <Show when={d().providerName}>
468
+ <Show when={data().providerName}>
436
469
  <text fg={pal().muted}>
437
- {justify(T.provider, d().providerName)}
470
+ {justify(T.provider, data().providerName)}
438
471
  </text>
439
472
  </Show>
440
473
 
441
474
  {/* model */}
442
475
  <text fg={pal().muted}>
443
- {justify(T.model, d().model)}
476
+ {justify(T.model, data().model)}
444
477
  </text>
445
478
 
446
479
  {/* rates */}
447
- <Show when={d().hasPricing}>
480
+ <Show when={data().hasPricing}>
448
481
  <text fg={pal().muted}>
449
- {justify(T.rate, "$" + d().inputRate.toFixed(2) + "/M " + T.inputRate)}
482
+ {justify(T.rate, "$" + data().inputRate.toFixed(2) + "/M " + T.inputRate)}
450
483
  </text>
451
- <Show when={d().cacheReadRate > 0}>
484
+ <Show when={data().cacheReadRate > 0}>
485
+ <text fg={pal().muted}>
486
+ {justify("", "$" + data().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
487
+ </text>
488
+ </Show>
489
+ <Show when={data().cacheWriteRate > 0}>
452
490
  <text fg={pal().muted}>
453
- {justify("", "$" + d().cacheReadRate.toFixed(2) + "/M " + T.cacheRate)}
491
+ {justify("", "$" + data().cacheWriteRate.toFixed(2) + "/M " + T.writeRate)}
454
492
  </text>
455
493
  </Show>
456
494
  </Show>
457
495
  </Show>
458
- </box>
459
- </Show>
496
+ </Show>
497
+ </box>
460
498
  )
461
499
  }
462
500