opencode-visual-cache 1.2.6 → 1.2.8

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
@@ -43,7 +43,6 @@
43
43
  - **折叠面板**:主标题默认折叠,点击展开;明细、模型、分布各自独立折叠
44
44
  - **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
45
45
  - **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示估算 Token 占比
46
- - **区块折叠**:明细、模型、Token 分布各自独立折叠
47
46
  - **折叠记忆**:折叠状态持久化,重启后保持
48
47
  - **语言适配**:自动检测系统语言
49
48
  - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算
@@ -98,7 +97,7 @@ npm install -g opencode-visual-cache@latest
98
97
  |------|------|---------|
99
98
  | `/cache-currency` | 切换货币单位 | 从列表选择货币(USD / CNY / EUR / JPY / GBP / KRW),自动填入默认汇率 |
100
99
  | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 |
101
- | `/cache-section` | 开关区块显示 | 独立控制「Token 明细」「模型与定价」「估算 Token 分布」的显隐 |
100
+ | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 面板边框的显隐 |
102
101
  | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 |
103
102
 
104
103
  <div align="center">
@@ -133,7 +132,7 @@ npm install -g opencode-visual-cache@latest
133
132
  - **模型与定价**:费用 / 提供商 / 模型名 / 单价
134
133
  - **估算 Token 分布**:按角色拆分的 Token 估算
135
134
 
136
- 通过 `/cache-section` 切换后即时生效,无需重启。
135
+ 通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。
137
136
 
138
137
  ---
139
138
 
@@ -165,12 +164,18 @@ rm -rf ~/.cache/opencode/packages/opencode-visual-cache@latest
165
164
 
166
165
  ## 6. 调试
167
166
 
168
- 强制英文(Windows PowerShell):
167
+ 强制英文:
169
168
 
170
169
  ```powershell
170
+ # Windows PowerShell
171
171
  $env:CACHE_TUI_LANG="en"; opencode
172
172
  ```
173
173
 
174
+ ```bash
175
+ # macOS / Linux
176
+ CACHE_TUI_LANG=en opencode
177
+ ```
178
+
174
179
  ---
175
180
 
176
181
  ## 7. 兼容性
package/README_EN.md CHANGED
@@ -97,7 +97,7 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime
97
97
  |---------|----------|------------|
98
98
  | `/cache-currency` | Switch currency | Pick from a list (USD / CNY / EUR / JPY / GBP / KRW); default exchange rate auto-filled |
99
99
  | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) |
100
- | `/cache-section` | Toggle sections | Independently show/hide Detail, Model & Pricing, or Token Distribution |
100
+ | `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, or the panel border |
101
101
  | `/cache-config` | View current config | Displays currency, rate, and section visibility |
102
102
 
103
103
  <div align="center">
@@ -132,7 +132,7 @@ Three sub-sections can be toggled independently to save sidebar space:
132
132
  - **Model & Pricing**: cost / provider / model name / per-million rates
133
133
  - **Estimated Token Dist.**: per-role token breakdown
134
134
 
135
- Toggled via `/cache-section` — takes effect instantly, no restart required.
135
+ Toggled via `/cache-section` — takes effect instantly, no restart required. The same command also toggles the panel **border**; turning it off removes the outline and padding so content fills the full width.
136
136
 
137
137
  ---
138
138
 
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.6";
1
+ export declare const PLUGIN_VERSION = "1.2.8";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.6";
2
+ export const PLUGIN_VERSION = "1.2.8";
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, onMount, onCleanup, Show } from "solid-js";
2
+ import { createMemo, createSignal, createEffect, 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
@@ -267,16 +267,6 @@ function estimateTokens(text) {
267
267
  const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
268
268
  return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
269
269
  }
270
- // ---------------------------------------------------------------------------
271
- // Sidebar component
272
- // ---------------------------------------------------------------------------
273
- /** Module-level config signals — shared between the TUI component and slash
274
- * commands so that runtime changes take effect immediately without restart. */
275
- const [currencySymbol, setCurrencySymbol] = createSignal("$");
276
- const [exchangeRate, setExchangeRate] = createSignal(1);
277
- const [sectionDetail, setSectionDetail] = createSignal(true);
278
- const [sectionModel, setSectionModel] = createSignal(true);
279
- const [sectionDist, setSectionDist] = createSignal(true);
280
270
  const CURRENCIES = {
281
271
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
282
272
  };
@@ -287,8 +277,6 @@ const DEFAULT_RATES = {
287
277
  };
288
278
  const MIN_PANEL_WIDTH = 20;
289
279
  const DEFAULT_PANEL_WIDTH = 26;
290
- /** Horizontal space eaten by border (1+1) + padding (2+2). */
291
- const GUTTER = 2 + 4;
292
280
  /** ── layout measurement constants (visual columns) ── */
293
281
  const LABEL_GAP = 1; // label(如 "Hit")后面的空格
294
282
  const BAR_BRACKETS = 2; // "[" + "]" 包围进度条
@@ -303,9 +291,21 @@ function TokenCachePanel(props) {
303
291
  const [modelOpen, setModelOpen] = createSignal(true);
304
292
  const [distOpen, setDistOpen] = createSignal(false);
305
293
  let boxEl;
294
+ // ── shared signals (de-structured so internal code is unchanged) ──
295
+ const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, borderVisible, setBorderVisible, } = props.signals;
306
296
  // ── scan session messages reactively ──
307
297
  // SolidJS createMemo re-evaluates whenever the underlying
308
298
  // api.state.session state changes — no event listener needed.
299
+ // ── distribution cache ────────────────────────────────────────
300
+ // When data() re-computes before api.state.part() is warm (e.g. after
301
+ // a view switch), hasDistData flips to false and the distribution
302
+ // block disappears. Keep the last valid snapshot so the UI stays
303
+ // stable until the next successful computation arrives.
304
+ const [lastDist, setLastDist] = createSignal({
305
+ system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
306
+ output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
307
+ });
308
+ const [lastHasDist, setLastHasDist] = createSignal(false);
309
309
  const data = createMemo(() => {
310
310
  const msgs = props.api.state.session.messages(props.sessionId);
311
311
  let input = 0;
@@ -487,6 +487,10 @@ function TokenCachePanel(props) {
487
487
  catch {
488
488
  // Graceful degradation — dist stays at zeroes
489
489
  }
490
+ // Fall back to last known-good distribution while api.state.part()
491
+ // is re-hydrating after a view switch.
492
+ const finalDist = hasDistData ? dist : lastDist();
493
+ const finalHasDist = hasDistData || lastHasDist();
490
494
  return {
491
495
  hitRate, read, write, freshInput: input, output,
492
496
  cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
@@ -494,9 +498,24 @@ function TokenCachePanel(props) {
494
498
  trend, hasTrendData,
495
499
  providerName,
496
500
  sessionHitRate,
497
- dist, hasDistData,
501
+ dist: finalDist,
502
+ hasDistData: finalHasDist,
498
503
  };
499
504
  });
505
+ // Persist the last valid distribution so that data() can fall back
506
+ // to it while api.state.part() is re-hydrating after a view switch.
507
+ createEffect(() => {
508
+ const d = data();
509
+ if (d.hasDistData) {
510
+ setLastDist({ ...d.dist });
511
+ setLastHasDist(true);
512
+ // Also persist across component remounts (view switches)
513
+ try {
514
+ props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist });
515
+ }
516
+ catch { }
517
+ }
518
+ });
500
519
  // ── token distribution (in-process via api.state.part) ──
501
520
  const [partVersion, setPartVersion] = createSignal(0);
502
521
  // Persist fold state to api.kv
@@ -508,7 +527,10 @@ function TokenCachePanel(props) {
508
527
  catch { }
509
528
  };
510
529
  onMount(() => {
511
- // Restore fold state from persisted storage
530
+ // Reset panelWidth on (re)mount so the layout uses a clean
531
+ // default until onSizeChange measures the live box dimensions.
532
+ setPanelWidth(DEFAULT_PANEL_WIDTH);
533
+ // Restore fold state from persisted storage (non-critical — fire and forget)
512
534
  try {
513
535
  setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)));
514
536
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)));
@@ -516,19 +538,51 @@ function TokenCachePanel(props) {
516
538
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
517
539
  }
518
540
  catch { }
519
- // Restore user config (currency, rate, section visibility)
520
- try {
521
- const sym = props.api.kv.get(`${KV_PREFIX}.currency`);
522
- const rate = props.api.kv.get(`${KV_PREFIX}.rate`);
523
- if (typeof sym === "string")
524
- setCurrencySymbol(sym);
525
- if (typeof rate === "number" && rate > 0)
526
- setExchangeRate(rate);
527
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
528
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
529
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
541
+ // Restore user config (currency, rate, section visibility).
542
+ // Try synchronously first (kv is usually ready on mount), fall back to
543
+ // polling if the module was reloaded and kv hasn't initialised yet.
544
+ const doRestore = () => {
545
+ try {
546
+ const sym = props.api.kv.get(`${KV_PREFIX}.currency`);
547
+ const rate = props.api.kv.get(`${KV_PREFIX}.rate`);
548
+ if (typeof sym === "string")
549
+ setCurrencySymbol(sym);
550
+ if (typeof rate === "number" && rate > 0)
551
+ setExchangeRate(rate);
552
+ setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
553
+ setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
554
+ setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
555
+ const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
556
+ setBorderVisible(bv !== false);
557
+ // Restore distribution snapshot so the token distribution block
558
+ // doesn't blank out while api.state.part() re-hydrates.
559
+ const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`);
560
+ if (cachedDist) {
561
+ setLastDist(cachedDist);
562
+ setLastHasDist(true);
563
+ }
564
+ }
565
+ catch {
566
+ // kv read failed — signals stay at defaults
567
+ }
568
+ // Re-measure panel width after config signals have settled
569
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
570
+ setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width));
571
+ }
572
+ };
573
+ if (props.api.kv.ready) {
574
+ doRestore();
575
+ }
576
+ else {
577
+ const pollRestore = () => {
578
+ if (!props.api.kv.ready) {
579
+ setTimeout(pollRestore, 10);
580
+ return;
581
+ }
582
+ doRestore();
583
+ };
584
+ pollRestore();
530
585
  }
531
- catch { }
532
586
  const unsubPart = props.api.event.on("message.part.updated", () => {
533
587
  setPartVersion((v) => v + 1);
534
588
  });
@@ -561,45 +615,72 @@ function TokenCachePanel(props) {
561
615
  return pal().warning;
562
616
  return pal().error;
563
617
  });
564
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - GUTTER)));
618
+ /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
619
+ const gutter = createMemo(() => borderVisible() ? 6 : 0);
620
+ const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())));
565
621
  function trendLabel(t) {
566
622
  return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "");
567
623
  }
568
624
  const barW = createMemo(() => {
569
625
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
570
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER;
626
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
571
627
  return Math.max(3, panelWidth() - overhead);
572
628
  });
573
629
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
574
630
  const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%");
631
+ // When border visibility changes the box dimensions shift, which
632
+ // may not reliably trigger onSizeChange across (re)mount cycles.
633
+ // Force panelWidth to resync with the live box after every change.
634
+ createEffect(() => {
635
+ borderVisible();
636
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
637
+ const w = Math.max(MIN_PANEL_WIDTH, boxEl.width);
638
+ setPanelWidth((prev) => (prev === w ? prev : w));
639
+ }
640
+ });
575
641
  // left-align label, right-align value — auto-fill space between
576
642
  const justify = (label, value, unit = "") => {
577
- const gauge = panelWidth() - GUTTER;
643
+ const gauge = panelWidth() - gutter();
578
644
  const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0);
579
645
  const gap = Math.max(1, gauge - used);
580
646
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
581
647
  };
582
- return (_jsxs("box", { border: true, borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: 2, paddingRight: 2, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
648
+ return (_jsxs("box", { border: borderVisible(), ...(borderVisible() ? { borderColor: pal().border } : {}), paddingTop: 0, paddingBottom: 0, paddingLeft: borderVisible() ? 2 : 0, paddingRight: borderVisible() ? 2 : 0, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
583
649
  // boxEl.width may be undefined before the first measurement — guard with 0
584
650
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
585
651
  setPanelWidth((prev) => (prev === w ? prev : w));
586
- }, 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(Show, { when: sectionDetail(), children: [_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, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_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, currencySymbol(), exchangeRate())) }), _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, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _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) })] })] }) })] }) })] }));
652
+ }, 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(Show, { when: sectionDetail(), children: [_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, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_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, currencySymbol(), exchangeRate())) }), _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, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _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) })] })] }) })] }) })] }));
587
653
  }
588
654
  // ---------------------------------------------------------------------------
589
655
  // Plugin entry
590
656
  // ---------------------------------------------------------------------------
591
- function createSidebarSlot(api) {
657
+ function createSidebarSlot(api, signals) {
592
658
  return {
593
659
  order: 55,
594
660
  slots: {
595
661
  sidebar_content(ctx, input) {
596
- return (_jsx(TokenCachePanel, { theme: ctx.theme.current, api: api, sessionId: input.session_id }));
662
+ return (_jsx(TokenCachePanel, { theme: ctx.theme.current, api: api, sessionId: input.session_id, signals: signals }));
597
663
  },
598
664
  },
599
665
  };
600
666
  }
601
667
  const tui = async (api) => {
602
- api.slots.register(createSidebarSlot(api));
668
+ // ── shared panel signals ──────────────────────────────────────
669
+ const [currencySymbol, setCurrencySymbol] = createSignal("$");
670
+ const [exchangeRate, setExchangeRate] = createSignal(1);
671
+ const [sectionDetail, setSectionDetail] = createSignal(true);
672
+ const [sectionModel, setSectionModel] = createSignal(true);
673
+ const [sectionDist, setSectionDist] = createSignal(true);
674
+ const [borderVisible, setBorderVisible] = createSignal(true);
675
+ const signals = {
676
+ currencySymbol, setCurrencySymbol,
677
+ exchangeRate, setExchangeRate,
678
+ sectionDetail, setSectionDetail,
679
+ sectionModel, setSectionModel,
680
+ sectionDist, setSectionDist,
681
+ borderVisible, setBorderVisible,
682
+ };
683
+ api.slots.register(createSidebarSlot(api, signals));
603
684
  // ── slash commands for runtime config ──
604
685
  const KV_PREFIX = "cache_panel";
605
686
  api.command?.register(() => [
@@ -617,8 +698,8 @@ const tui = async (api) => {
617
698
  const defRate = DEFAULT_RATES[opt.value] ?? 1;
618
699
  api.kv.set(`${KV_PREFIX}.currency`, sym);
619
700
  api.kv.set(`${KV_PREFIX}.rate`, defRate);
620
- setCurrencySymbol(sym);
621
- setExchangeRate(defRate);
701
+ signals.setCurrencySymbol(sym);
702
+ signals.setExchangeRate(defRate);
622
703
  api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` });
623
704
  dialog?.clear();
624
705
  } })));
@@ -634,7 +715,7 @@ const tui = async (api) => {
634
715
  const n = parseFloat(val);
635
716
  if (n > 0) {
636
717
  api.kv.set(`${KV_PREFIX}.rate`, n);
637
- setExchangeRate(n);
718
+ signals.setExchangeRate(n);
638
719
  api.ui.toast({ message: `Exchange rate set to ${n}` });
639
720
  }
640
721
  dialog?.clear();
@@ -650,21 +731,31 @@ const tui = async (api) => {
650
731
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
651
732
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
652
733
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
734
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
653
735
  dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
654
736
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
655
737
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
656
738
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
739
+ { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
657
740
  ], onSelect: (opt) => {
658
- const key = `${KV_PREFIX}.section.${opt.value}`;
659
- const cur = Boolean(api.kv.get(key, true));
660
- api.kv.set(key, !cur);
661
- if (opt.value === "detail")
662
- setSectionDetail(!cur);
663
- if (opt.value === "model")
664
- setSectionModel(!cur);
665
- if (opt.value === "dist")
666
- setSectionDist(!cur);
667
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
741
+ if (opt.value === "border") {
742
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
743
+ api.kv.set(`${KV_PREFIX}.border`, !cur);
744
+ signals.setBorderVisible(!cur);
745
+ api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` });
746
+ }
747
+ else {
748
+ const key = `${KV_PREFIX}.section.${opt.value}`;
749
+ const cur = Boolean(api.kv.get(key, true));
750
+ api.kv.set(key, !cur);
751
+ if (opt.value === "detail")
752
+ signals.setSectionDetail(!cur);
753
+ if (opt.value === "model")
754
+ signals.setSectionModel(!cur);
755
+ if (opt.value === "dist")
756
+ signals.setSectionDist(!cur);
757
+ api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
758
+ }
668
759
  dialog?.clear();
669
760
  } })));
670
761
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
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/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.2.6";
2
+ export const PLUGIN_VERSION="1.2.8";
package/src/index.tsx CHANGED
@@ -17,7 +17,7 @@ import type {
17
17
  FilePart,
18
18
  ReasoningPart,
19
19
  } from "@opencode-ai/sdk/v2"
20
- import { createMemo, createSignal, onMount, onCleanup, Show } from "solid-js"
20
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
21
21
  import { PLUGIN_VERSION } from "./_version"
22
22
 
23
23
  // ---------------------------------------------------------------------------
@@ -306,13 +306,23 @@ interface TokenDist {
306
306
  // Sidebar component
307
307
  // ---------------------------------------------------------------------------
308
308
 
309
- /** Module-level config signals — shared between the TUI component and slash
310
- * commands so that runtime changes take effect immediately without restart. */
311
- const [currencySymbol, setCurrencySymbol] = createSignal("$")
312
- const [exchangeRate, setExchangeRate] = createSignal(1)
313
- const [sectionDetail, setSectionDetail] = createSignal(true)
314
- const [sectionModel, setSectionModel] = createSignal(true)
315
- const [sectionDist, setSectionDist] = createSignal(true)
309
+ /** Signals shared between the TUI component and slash commands.
310
+ * Created in the `tui` function scope so they do not survive module reload —
311
+ * the component re-creates them on mount and restores user config from kv. */
312
+ interface PanelSignals {
313
+ currencySymbol: () => string
314
+ setCurrencySymbol: (v: string) => void
315
+ exchangeRate: () => number
316
+ setExchangeRate: (v: number) => void
317
+ sectionDetail: () => boolean
318
+ setSectionDetail: (v: boolean) => void
319
+ sectionModel: () => boolean
320
+ setSectionModel: (v: boolean) => void
321
+ sectionDist: () => boolean
322
+ setSectionDist: (v: boolean) => void
323
+ borderVisible: () => boolean
324
+ setBorderVisible: (v: boolean) => void
325
+ }
316
326
 
317
327
  const CURRENCIES: Record<string, string> = {
318
328
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
@@ -325,8 +335,6 @@ const DEFAULT_RATES: Record<string, number> = {
325
335
 
326
336
  const MIN_PANEL_WIDTH = 20
327
337
  const DEFAULT_PANEL_WIDTH = 26
328
- /** Horizontal space eaten by border (1+1) + padding (2+2). */
329
- const GUTTER = 2 + 4
330
338
 
331
339
  /** ── layout measurement constants (visual columns) ── */
332
340
  const LABEL_GAP = 1 // label(如 "Hit")后面的空格
@@ -341,6 +349,7 @@ function TokenCachePanel(props: {
341
349
  theme: TuiThemeCurrent
342
350
  api: TuiPluginApi
343
351
  sessionId: string
352
+ signals: PanelSignals
344
353
  }): JSX.Element {
345
354
  const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
346
355
  const [open, setOpen] = createSignal(true)
@@ -349,9 +358,31 @@ function TokenCachePanel(props: {
349
358
  const [distOpen, setDistOpen] = createSignal(false)
350
359
  let boxEl: any
351
360
 
361
+ // ── shared signals (de-structured so internal code is unchanged) ──
362
+ const {
363
+ currencySymbol, setCurrencySymbol,
364
+ exchangeRate, setExchangeRate,
365
+ sectionDetail, setSectionDetail,
366
+ sectionModel, setSectionModel,
367
+ sectionDist, setSectionDist,
368
+ borderVisible, setBorderVisible,
369
+ } = props.signals
370
+
352
371
  // ── scan session messages reactively ──
353
372
  // SolidJS createMemo re-evaluates whenever the underlying
354
373
  // api.state.session state changes — no event listener needed.
374
+
375
+ // ── distribution cache ────────────────────────────────────────
376
+ // When data() re-computes before api.state.part() is warm (e.g. after
377
+ // a view switch), hasDistData flips to false and the distribution
378
+ // block disappears. Keep the last valid snapshot so the UI stays
379
+ // stable until the next successful computation arrives.
380
+ const [lastDist, setLastDist] = createSignal<TokenDist>({
381
+ system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0,
382
+ output: 0, apiOutput: 0, apiInput: 0, stepCost: 0,
383
+ })
384
+ const [lastHasDist, setLastHasDist] = createSignal(false)
385
+
355
386
  const data = createMemo(() => {
356
387
  const msgs = props.api.state.session.messages(props.sessionId) as Message[]
357
388
 
@@ -518,6 +549,11 @@ function TokenCachePanel(props: {
518
549
  // Graceful degradation — dist stays at zeroes
519
550
  }
520
551
 
552
+ // Fall back to last known-good distribution while api.state.part()
553
+ // is re-hydrating after a view switch.
554
+ const finalDist = hasDistData ? dist : lastDist()
555
+ const finalHasDist = hasDistData || lastHasDist()
556
+
521
557
  return {
522
558
  hitRate, read, write, freshInput: input, output,
523
559
  cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
@@ -525,7 +561,20 @@ function TokenCachePanel(props: {
525
561
  trend, hasTrendData,
526
562
  providerName,
527
563
  sessionHitRate,
528
- dist, hasDistData,
564
+ dist: finalDist,
565
+ hasDistData: finalHasDist,
566
+ }
567
+ })
568
+
569
+ // Persist the last valid distribution so that data() can fall back
570
+ // to it while api.state.part() is re-hydrating after a view switch.
571
+ createEffect(() => {
572
+ const d = data()
573
+ if (d.hasDistData) {
574
+ setLastDist({ ...d.dist })
575
+ setLastHasDist(true)
576
+ // Also persist across component remounts (view switches)
577
+ try { props.api.kv.set(`${KV_PREFIX}.dist_snapshot`, { ...d.dist }) } catch {}
529
578
  }
530
579
  })
531
580
 
@@ -539,7 +588,11 @@ function TokenCachePanel(props: {
539
588
  }
540
589
 
541
590
  onMount(() => {
542
- // Restore fold state from persisted storage
591
+ // Reset panelWidth on (re)mount so the layout uses a clean
592
+ // default until onSizeChange measures the live box dimensions.
593
+ setPanelWidth(DEFAULT_PANEL_WIDTH)
594
+
595
+ // Restore fold state from persisted storage (non-critical — fire and forget)
543
596
  try {
544
597
  setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)))
545
598
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
@@ -547,16 +600,48 @@ function TokenCachePanel(props: {
547
600
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
548
601
  } catch {}
549
602
 
550
- // Restore user config (currency, rate, section visibility)
551
- try {
552
- const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
553
- const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
554
- if (typeof sym === "string") setCurrencySymbol(sym)
555
- if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
556
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
557
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
558
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
559
- } catch {}
603
+ // Restore user config (currency, rate, section visibility).
604
+ // Try synchronously first (kv is usually ready on mount), fall back to
605
+ // polling if the module was reloaded and kv hasn't initialised yet.
606
+ const doRestore = () => {
607
+ try {
608
+ const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
609
+ const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
610
+ if (typeof sym === "string") setCurrencySymbol(sym)
611
+ if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
612
+ setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
613
+ setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
614
+ setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
615
+ const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
616
+ setBorderVisible(bv !== false)
617
+ // Restore distribution snapshot so the token distribution block
618
+ // doesn't blank out while api.state.part() re-hydrates.
619
+ const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
620
+ if (cachedDist) {
621
+ setLastDist(cachedDist)
622
+ setLastHasDist(true)
623
+ }
624
+ } catch {
625
+ // kv read failed — signals stay at defaults
626
+ }
627
+ // Re-measure panel width after config signals have settled
628
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
629
+ setPanelWidth(Math.max(MIN_PANEL_WIDTH, boxEl.width))
630
+ }
631
+ }
632
+
633
+ if (props.api.kv.ready) {
634
+ doRestore()
635
+ } else {
636
+ const pollRestore = () => {
637
+ if (!props.api.kv.ready) {
638
+ setTimeout(pollRestore, 10)
639
+ return
640
+ }
641
+ doRestore()
642
+ }
643
+ pollRestore()
644
+ }
560
645
 
561
646
  const unsubPart = props.api.event.on("message.part.updated", () => {
562
647
  setPartVersion((v) => v + 1)
@@ -591,22 +676,36 @@ function TokenCachePanel(props: {
591
676
  return pal().error
592
677
  })
593
678
 
594
- const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - GUTTER)))
679
+ /** Horizontal space eaten by border (1+1 when visible) + padding (2+2 when visible). */
680
+ const gutter = createMemo(() => borderVisible() ? 6 : 0)
681
+
682
+ const sep = createMemo(() => "\u2500".repeat(Math.max(1, panelWidth() - gutter())))
595
683
  function trendLabel(t: number): string {
596
684
  return (t > 0 ? "\u2191" : t < 0 ? "\u2193" : "-") + (t !== 0 ? Math.abs(t).toFixed(1) + "%" : "")
597
685
  }
598
686
 
599
687
  const barW = createMemo(() => {
600
688
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
601
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + GUTTER
689
+ const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
602
690
  return Math.max(3, panelWidth() - overhead)
603
691
  })
604
692
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
605
693
  const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
606
694
 
695
+ // When border visibility changes the box dimensions shift, which
696
+ // may not reliably trigger onSizeChange across (re)mount cycles.
697
+ // Force panelWidth to resync with the live box after every change.
698
+ createEffect(() => {
699
+ borderVisible()
700
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
701
+ const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
702
+ setPanelWidth((prev) => (prev === w ? prev : w))
703
+ }
704
+ })
705
+
607
706
  // left-align label, right-align value — auto-fill space between
608
707
  const justify = (label: string, value: string, unit = ""): string => {
609
- const gauge = panelWidth() - GUTTER
708
+ const gauge = panelWidth() - gutter()
610
709
  const used = visualWidth(label) + visualWidth(value) + (unit ? visualWidth(unit) + UNIT_GAP : 0)
611
710
  const gap = Math.max(1, gauge - used)
612
711
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "")
@@ -614,12 +713,12 @@ function TokenCachePanel(props: {
614
713
 
615
714
  return (
616
715
  <box
617
- border
618
- borderColor={pal().border}
716
+ border={borderVisible()}
717
+ {...(borderVisible() ? { borderColor: pal().border } : {})}
619
718
  paddingTop={0}
620
719
  paddingBottom={0}
621
- paddingLeft={2}
622
- paddingRight={2}
720
+ paddingLeft={borderVisible() ? 2 : 0}
721
+ paddingRight={borderVisible() ? 2 : 0}
623
722
  flexDirection="column"
624
723
  gap={0}
625
724
  ref={boxEl}
@@ -641,7 +740,7 @@ function TokenCachePanel(props: {
641
740
  <Show when={!open() && data().hasData}>
642
741
  <Show when={data().hasTrendData}>
643
742
  <span>
644
- {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
743
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
645
744
  </span>
646
745
  <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
647
746
  <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
@@ -650,7 +749,7 @@ function TokenCachePanel(props: {
650
749
  </Show>
651
750
  <Show when={!data().hasTrendData}>
652
751
  <span>
653
- {" ".repeat(Math.max(1, panelWidth() - GUTTER - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
752
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
654
753
  </span>
655
754
  <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
656
755
  </Show>
@@ -714,7 +813,7 @@ function TokenCachePanel(props: {
714
813
  <Show when={data().saved > 0}>
715
814
  <text>
716
815
  <span style={{ fg: pal().muted }}>{T.saved}</span>
717
- <span>{" ".repeat(Math.max(1, panelWidth() - GUTTER - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
816
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
718
817
  <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
719
818
  </text>
720
819
  </Show>
@@ -809,7 +908,7 @@ function TokenCachePanel(props: {
809
908
  // Plugin entry
810
909
  // ---------------------------------------------------------------------------
811
910
 
812
- function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
911
+ function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
813
912
  return {
814
913
  order: 55,
815
914
  slots: {
@@ -819,6 +918,7 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
819
918
  theme={ctx.theme.current}
820
919
  api={api}
821
920
  sessionId={input.session_id}
921
+ signals={signals}
822
922
  />
823
923
  )
824
924
  },
@@ -827,7 +927,24 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
827
927
  }
828
928
 
829
929
  const tui: TuiPlugin = async (api: TuiPluginApi) => {
830
- api.slots.register(createSidebarSlot(api))
930
+ // ── shared panel signals ──────────────────────────────────────
931
+ const [currencySymbol, setCurrencySymbol] = createSignal("$")
932
+ const [exchangeRate, setExchangeRate] = createSignal(1)
933
+ const [sectionDetail, setSectionDetail] = createSignal(true)
934
+ const [sectionModel, setSectionModel] = createSignal(true)
935
+ const [sectionDist, setSectionDist] = createSignal(true)
936
+ const [borderVisible, setBorderVisible] = createSignal(true)
937
+
938
+ const signals: PanelSignals = {
939
+ currencySymbol, setCurrencySymbol,
940
+ exchangeRate, setExchangeRate,
941
+ sectionDetail, setSectionDetail,
942
+ sectionModel, setSectionModel,
943
+ sectionDist, setSectionDist,
944
+ borderVisible, setBorderVisible,
945
+ }
946
+
947
+ api.slots.register(createSidebarSlot(api, signals))
831
948
 
832
949
  // ── slash commands for runtime config ──
833
950
  const KV_PREFIX = "cache_panel"
@@ -850,8 +967,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
850
967
  const defRate = DEFAULT_RATES[opt.value] ?? 1
851
968
  api.kv.set(`${KV_PREFIX}.currency`, sym)
852
969
  api.kv.set(`${KV_PREFIX}.rate`, defRate)
853
- setCurrencySymbol(sym)
854
- setExchangeRate(defRate)
970
+ signals.setCurrencySymbol(sym)
971
+ signals.setExchangeRate(defRate)
855
972
  api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
856
973
  dialog?.clear()
857
974
  }}
@@ -875,7 +992,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
875
992
  const n = parseFloat(val)
876
993
  if (n > 0) {
877
994
  api.kv.set(`${KV_PREFIX}.rate`, n)
878
- setExchangeRate(n)
995
+ signals.setExchangeRate(n)
879
996
  api.ui.toast({ message: `Exchange rate set to ${n}` })
880
997
  }
881
998
  dialog?.clear()
@@ -893,6 +1010,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
893
1010
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
894
1011
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
895
1012
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1013
+ const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
896
1014
  dialog?.replace(() => (
897
1015
  <api.ui.DialogSelect
898
1016
  title="Toggle Section"
@@ -900,15 +1018,23 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
900
1018
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
901
1019
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
902
1020
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1021
+ { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
903
1022
  ]}
904
1023
  onSelect={(opt) => {
905
- const key = `${KV_PREFIX}.section.${opt.value}`
906
- const cur = Boolean(api.kv.get(key, true))
907
- api.kv.set(key, !cur)
908
- if (opt.value === "detail") setSectionDetail(!cur)
909
- if (opt.value === "model") setSectionModel(!cur)
910
- if (opt.value === "dist") setSectionDist(!cur)
911
- api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1024
+ if (opt.value === "border") {
1025
+ const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
1026
+ api.kv.set(`${KV_PREFIX}.border`, !cur)
1027
+ signals.setBorderVisible(!cur)
1028
+ api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
1029
+ } else {
1030
+ const key = `${KV_PREFIX}.section.${opt.value}`
1031
+ const cur = Boolean(api.kv.get(key, true))
1032
+ api.kv.set(key, !cur)
1033
+ if (opt.value === "detail") signals.setSectionDetail(!cur)
1034
+ if (opt.value === "model") signals.setSectionModel(!cur)
1035
+ if (opt.value === "dist") signals.setSectionDist(!cur)
1036
+ api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
1037
+ }
912
1038
  dialog?.clear()
913
1039
  }}
914
1040
  />