opencode-visual-cache 1.2.7 → 1.2.9-beta.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.
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.7";
1
+ export declare const PLUGIN_VERSION = "1.2.9-beta.0";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.7";
2
+ export const PLUGIN_VERSION = "1.2.9-beta.0";
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,17 +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
- const [borderVisible, setBorderVisible] = createSignal(true);
281
270
  const CURRENCIES = {
282
271
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
283
272
  };
@@ -302,9 +291,21 @@ function TokenCachePanel(props) {
302
291
  const [modelOpen, setModelOpen] = createSignal(true);
303
292
  const [distOpen, setDistOpen] = createSignal(false);
304
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;
305
296
  // ── scan session messages reactively ──
306
297
  // SolidJS createMemo re-evaluates whenever the underlying
307
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);
308
309
  const data = createMemo(() => {
309
310
  const msgs = props.api.state.session.messages(props.sessionId);
310
311
  let input = 0;
@@ -486,6 +487,10 @@ function TokenCachePanel(props) {
486
487
  catch {
487
488
  // Graceful degradation — dist stays at zeroes
488
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();
489
494
  return {
490
495
  hitRate, read, write, freshInput: input, output,
491
496
  cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
@@ -493,9 +498,24 @@ function TokenCachePanel(props) {
493
498
  trend, hasTrendData,
494
499
  providerName,
495
500
  sessionHitRate,
496
- dist, hasDistData,
501
+ dist: finalDist,
502
+ hasDistData: finalHasDist,
497
503
  };
498
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
+ });
499
519
  // ── token distribution (in-process via api.state.part) ──
500
520
  const [partVersion, setPartVersion] = createSignal(0);
501
521
  // Persist fold state to api.kv
@@ -507,7 +527,10 @@ function TokenCachePanel(props) {
507
527
  catch { }
508
528
  };
509
529
  onMount(() => {
510
- // 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)
511
534
  try {
512
535
  setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)));
513
536
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)));
@@ -515,29 +538,71 @@ function TokenCachePanel(props) {
515
538
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
516
539
  }
517
540
  catch { }
518
- // Restore user config (currency, rate, section visibility)
519
- try {
520
- const sym = props.api.kv.get(`${KV_PREFIX}.currency`);
521
- const rate = props.api.kv.get(`${KV_PREFIX}.rate`);
522
- if (typeof sym === "string")
523
- setCurrencySymbol(sym);
524
- if (typeof rate === "number" && rate > 0)
525
- setExchangeRate(rate);
526
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
527
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
528
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
529
- const borderVal = props.api.kv.get(`${KV_PREFIX}.border`, true);
530
- setBorderVisible(borderVal !== false);
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();
531
575
  }
532
- catch { }
533
- // (border visibility was restored above together with section config)
534
- const unsubPart = props.api.event.on("message.part.updated", () => {
535
- setPartVersion((v) => v + 1);
536
- });
537
- const unsubMsg = props.api.event.on("message.updated", () => {
538
- setPartVersion((v) => v + 1);
539
- });
540
- onCleanup(() => { unsubPart(); unsubMsg(); });
576
+ else {
577
+ // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
578
+ // on platforms where kv initialisation may be delayed (Linux single-thread
579
+ // mode, session switch storms, etc.).
580
+ const MAX_POLL = 100;
581
+ let tries = 0;
582
+ const pollRestore = () => {
583
+ if (!props.api.kv.ready) {
584
+ if (++tries > MAX_POLL) {
585
+ doRestore();
586
+ return;
587
+ }
588
+ setTimeout(pollRestore, 10);
589
+ return;
590
+ }
591
+ doRestore();
592
+ };
593
+ pollRestore();
594
+ }
595
+ // Debounce partVersion updates so that event bursts during session
596
+ // switching / streaming don't cause data() to re-compute on every
597
+ // single event (up to hundreds per second on Linux single-thread).
598
+ let partTimer;
599
+ const bumpPartVersion = () => {
600
+ clearTimeout(partTimer);
601
+ partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100);
602
+ };
603
+ const unsubPart = props.api.event.on("message.part.updated", bumpPartVersion);
604
+ const unsubMsg = props.api.event.on("message.updated", bumpPartVersion);
605
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg(); });
541
606
  });
542
607
  // ── colours ──
543
608
  // Pull from the current theme, auto-desaturate if too punchy,
@@ -576,6 +641,16 @@ function TokenCachePanel(props) {
576
641
  });
577
642
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
578
643
  const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%");
644
+ // When border visibility changes the box dimensions shift, which
645
+ // may not reliably trigger onSizeChange across (re)mount cycles.
646
+ // Force panelWidth to resync with the live box after every change.
647
+ createEffect(() => {
648
+ borderVisible();
649
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
650
+ const w = Math.max(MIN_PANEL_WIDTH, boxEl.width);
651
+ setPanelWidth((prev) => (prev === w ? prev : w));
652
+ }
653
+ });
579
654
  // left-align label, right-align value — auto-fill space between
580
655
  const justify = (label, value, unit = "") => {
581
656
  const gauge = panelWidth() - gutter();
@@ -583,7 +658,7 @@ function TokenCachePanel(props) {
583
658
  const gap = Math.max(1, gauge - used);
584
659
  return label + " ".repeat(gap) + value + (unit ? " " + unit : "");
585
660
  };
586
- return (_jsxs("box", { border: borderVisible(), borderColor: pal().border, paddingTop: 0, paddingBottom: 0, paddingLeft: borderVisible() ? 2 : 0, paddingRight: borderVisible() ? 2 : 0, flexDirection: "column", gap: 0, ref: boxEl, onSizeChange: () => {
661
+ 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: () => {
587
662
  // boxEl.width may be undefined before the first measurement — guard with 0
588
663
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
589
664
  setPanelWidth((prev) => (prev === w ? prev : w));
@@ -592,18 +667,33 @@ function TokenCachePanel(props) {
592
667
  // ---------------------------------------------------------------------------
593
668
  // Plugin entry
594
669
  // ---------------------------------------------------------------------------
595
- function createSidebarSlot(api) {
670
+ function createSidebarSlot(api, signals) {
596
671
  return {
597
672
  order: 55,
598
673
  slots: {
599
674
  sidebar_content(ctx, input) {
600
- return (_jsx(TokenCachePanel, { theme: ctx.theme.current, api: api, sessionId: input.session_id }));
675
+ return (_jsx(TokenCachePanel, { theme: ctx.theme.current, api: api, sessionId: input.session_id, signals: signals }));
601
676
  },
602
677
  },
603
678
  };
604
679
  }
605
680
  const tui = async (api) => {
606
- api.slots.register(createSidebarSlot(api));
681
+ // ── shared panel signals ──────────────────────────────────────
682
+ const [currencySymbol, setCurrencySymbol] = createSignal("$");
683
+ const [exchangeRate, setExchangeRate] = createSignal(1);
684
+ const [sectionDetail, setSectionDetail] = createSignal(true);
685
+ const [sectionModel, setSectionModel] = createSignal(true);
686
+ const [sectionDist, setSectionDist] = createSignal(true);
687
+ const [borderVisible, setBorderVisible] = createSignal(true);
688
+ const signals = {
689
+ currencySymbol, setCurrencySymbol,
690
+ exchangeRate, setExchangeRate,
691
+ sectionDetail, setSectionDetail,
692
+ sectionModel, setSectionModel,
693
+ sectionDist, setSectionDist,
694
+ borderVisible, setBorderVisible,
695
+ };
696
+ api.slots.register(createSidebarSlot(api, signals));
607
697
  // ── slash commands for runtime config ──
608
698
  const KV_PREFIX = "cache_panel";
609
699
  api.command?.register(() => [
@@ -621,8 +711,8 @@ const tui = async (api) => {
621
711
  const defRate = DEFAULT_RATES[opt.value] ?? 1;
622
712
  api.kv.set(`${KV_PREFIX}.currency`, sym);
623
713
  api.kv.set(`${KV_PREFIX}.rate`, defRate);
624
- setCurrencySymbol(sym);
625
- setExchangeRate(defRate);
714
+ signals.setCurrencySymbol(sym);
715
+ signals.setExchangeRate(defRate);
626
716
  api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` });
627
717
  dialog?.clear();
628
718
  } })));
@@ -638,7 +728,7 @@ const tui = async (api) => {
638
728
  const n = parseFloat(val);
639
729
  if (n > 0) {
640
730
  api.kv.set(`${KV_PREFIX}.rate`, n);
641
- setExchangeRate(n);
731
+ signals.setExchangeRate(n);
642
732
  api.ui.toast({ message: `Exchange rate set to ${n}` });
643
733
  }
644
734
  dialog?.clear();
@@ -664,7 +754,7 @@ const tui = async (api) => {
664
754
  if (opt.value === "border") {
665
755
  const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
666
756
  api.kv.set(`${KV_PREFIX}.border`, !cur);
667
- setBorderVisible(!cur);
757
+ signals.setBorderVisible(!cur);
668
758
  api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` });
669
759
  }
670
760
  else {
@@ -672,11 +762,11 @@ const tui = async (api) => {
672
762
  const cur = Boolean(api.kv.get(key, true));
673
763
  api.kv.set(key, !cur);
674
764
  if (opt.value === "detail")
675
- setSectionDetail(!cur);
765
+ signals.setSectionDetail(!cur);
676
766
  if (opt.value === "model")
677
- setSectionModel(!cur);
767
+ signals.setSectionModel(!cur);
678
768
  if (opt.value === "dist")
679
- setSectionDist(!cur);
769
+ signals.setSectionDist(!cur);
680
770
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
681
771
  }
682
772
  dialog?.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.2.7",
3
+ "version": "1.2.9-beta.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/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.2.7";
2
+ export const PLUGIN_VERSION="1.2.9-beta.0";
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,14 +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)
316
- const [borderVisible, setBorderVisible] = 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
+ }
317
326
 
318
327
  const CURRENCIES: Record<string, string> = {
319
328
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
@@ -340,6 +349,7 @@ function TokenCachePanel(props: {
340
349
  theme: TuiThemeCurrent
341
350
  api: TuiPluginApi
342
351
  sessionId: string
352
+ signals: PanelSignals
343
353
  }): JSX.Element {
344
354
  const [panelWidth, setPanelWidth] = createSignal(DEFAULT_PANEL_WIDTH)
345
355
  const [open, setOpen] = createSignal(true)
@@ -348,9 +358,31 @@ function TokenCachePanel(props: {
348
358
  const [distOpen, setDistOpen] = createSignal(false)
349
359
  let boxEl: any
350
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
+
351
371
  // ── scan session messages reactively ──
352
372
  // SolidJS createMemo re-evaluates whenever the underlying
353
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
+
354
386
  const data = createMemo(() => {
355
387
  const msgs = props.api.state.session.messages(props.sessionId) as Message[]
356
388
 
@@ -517,6 +549,11 @@ function TokenCachePanel(props: {
517
549
  // Graceful degradation — dist stays at zeroes
518
550
  }
519
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
+
520
557
  return {
521
558
  hitRate, read, write, freshInput: input, output,
522
559
  cost, saved, model, inputRate, cacheReadRate, cacheWriteRate, hasPricing,
@@ -524,7 +561,20 @@ function TokenCachePanel(props: {
524
561
  trend, hasTrendData,
525
562
  providerName,
526
563
  sessionHitRate,
527
- 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 {}
528
578
  }
529
579
  })
530
580
 
@@ -538,7 +588,11 @@ function TokenCachePanel(props: {
538
588
  }
539
589
 
540
590
  onMount(() => {
541
- // 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)
542
596
  try {
543
597
  setOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.open`, false)))
544
598
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
@@ -546,28 +600,66 @@ function TokenCachePanel(props: {
546
600
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
547
601
  } catch {}
548
602
 
549
- // Restore user config (currency, rate, section visibility)
550
- try {
551
- const sym = props.api.kv.get<string>(`${KV_PREFIX}.currency`)
552
- const rate = props.api.kv.get<number>(`${KV_PREFIX}.rate`)
553
- if (typeof sym === "string") setCurrencySymbol(sym)
554
- if (typeof rate === "number" && rate > 0) setExchangeRate(rate)
555
- setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
556
- setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
557
- setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
558
- const borderVal = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
559
- setBorderVisible(borderVal !== false)
560
- } 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
+ }
561
632
 
562
- // (border visibility was restored above together with section config)
633
+ if (props.api.kv.ready) {
634
+ doRestore()
635
+ } else {
636
+ // Poll kv.ready with a 1-second timeout to avoid infinite busy-wait
637
+ // on platforms where kv initialisation may be delayed (Linux single-thread
638
+ // mode, session switch storms, etc.).
639
+ const MAX_POLL = 100
640
+ let tries = 0
641
+ const pollRestore = () => {
642
+ if (!props.api.kv.ready) {
643
+ if (++tries > MAX_POLL) { doRestore(); return }
644
+ setTimeout(pollRestore, 10)
645
+ return
646
+ }
647
+ doRestore()
648
+ }
649
+ pollRestore()
650
+ }
563
651
 
564
- const unsubPart = props.api.event.on("message.part.updated", () => {
565
- setPartVersion((v) => v + 1)
566
- })
567
- const unsubMsg = props.api.event.on("message.updated", () => {
568
- setPartVersion((v) => v + 1)
569
- })
570
- onCleanup(() => { unsubPart(); unsubMsg() })
652
+ // Debounce partVersion updates so that event bursts during session
653
+ // switching / streaming don't cause data() to re-compute on every
654
+ // single event (up to hundreds per second on Linux single-thread).
655
+ let partTimer: ReturnType<typeof setTimeout> | undefined
656
+ const bumpPartVersion = () => {
657
+ clearTimeout(partTimer)
658
+ partTimer = setTimeout(() => setPartVersion((v) => v + 1), 100)
659
+ }
660
+ const unsubPart = props.api.event.on("message.part.updated", bumpPartVersion)
661
+ const unsubMsg = props.api.event.on("message.updated", bumpPartVersion)
662
+ onCleanup(() => { clearTimeout(partTimer); unsubPart(); unsubMsg() })
571
663
  })
572
664
 
573
665
  // ── colours ──
@@ -610,6 +702,17 @@ function TokenCachePanel(props: {
610
702
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
611
703
  const pct = createMemo(() => (Math.floor(data().hitRate * 10) / 10).toFixed(1) + "%")
612
704
 
705
+ // When border visibility changes the box dimensions shift, which
706
+ // may not reliably trigger onSizeChange across (re)mount cycles.
707
+ // Force panelWidth to resync with the live box after every change.
708
+ createEffect(() => {
709
+ borderVisible()
710
+ if (boxEl && typeof boxEl.width === "number" && boxEl.width > 0) {
711
+ const w = Math.max(MIN_PANEL_WIDTH, boxEl.width)
712
+ setPanelWidth((prev) => (prev === w ? prev : w))
713
+ }
714
+ })
715
+
613
716
  // left-align label, right-align value — auto-fill space between
614
717
  const justify = (label: string, value: string, unit = ""): string => {
615
718
  const gauge = panelWidth() - gutter()
@@ -621,7 +724,7 @@ function TokenCachePanel(props: {
621
724
  return (
622
725
  <box
623
726
  border={borderVisible()}
624
- borderColor={pal().border}
727
+ {...(borderVisible() ? { borderColor: pal().border } : {})}
625
728
  paddingTop={0}
626
729
  paddingBottom={0}
627
730
  paddingLeft={borderVisible() ? 2 : 0}
@@ -815,7 +918,7 @@ function TokenCachePanel(props: {
815
918
  // Plugin entry
816
919
  // ---------------------------------------------------------------------------
817
920
 
818
- function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
921
+ function createSidebarSlot(api: TuiPluginApi, signals: PanelSignals): TuiSlotPlugin {
819
922
  return {
820
923
  order: 55,
821
924
  slots: {
@@ -825,6 +928,7 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
825
928
  theme={ctx.theme.current}
826
929
  api={api}
827
930
  sessionId={input.session_id}
931
+ signals={signals}
828
932
  />
829
933
  )
830
934
  },
@@ -833,7 +937,24 @@ function createSidebarSlot(api: TuiPluginApi): TuiSlotPlugin {
833
937
  }
834
938
 
835
939
  const tui: TuiPlugin = async (api: TuiPluginApi) => {
836
- api.slots.register(createSidebarSlot(api))
940
+ // ── shared panel signals ──────────────────────────────────────
941
+ const [currencySymbol, setCurrencySymbol] = createSignal("$")
942
+ const [exchangeRate, setExchangeRate] = createSignal(1)
943
+ const [sectionDetail, setSectionDetail] = createSignal(true)
944
+ const [sectionModel, setSectionModel] = createSignal(true)
945
+ const [sectionDist, setSectionDist] = createSignal(true)
946
+ const [borderVisible, setBorderVisible] = createSignal(true)
947
+
948
+ const signals: PanelSignals = {
949
+ currencySymbol, setCurrencySymbol,
950
+ exchangeRate, setExchangeRate,
951
+ sectionDetail, setSectionDetail,
952
+ sectionModel, setSectionModel,
953
+ sectionDist, setSectionDist,
954
+ borderVisible, setBorderVisible,
955
+ }
956
+
957
+ api.slots.register(createSidebarSlot(api, signals))
837
958
 
838
959
  // ── slash commands for runtime config ──
839
960
  const KV_PREFIX = "cache_panel"
@@ -856,8 +977,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
856
977
  const defRate = DEFAULT_RATES[opt.value] ?? 1
857
978
  api.kv.set(`${KV_PREFIX}.currency`, sym)
858
979
  api.kv.set(`${KV_PREFIX}.rate`, defRate)
859
- setCurrencySymbol(sym)
860
- setExchangeRate(defRate)
980
+ signals.setCurrencySymbol(sym)
981
+ signals.setExchangeRate(defRate)
861
982
  api.ui.toast({ message: `Currency: ${opt.value} (${sym}), rate: ${defRate}` })
862
983
  dialog?.clear()
863
984
  }}
@@ -881,7 +1002,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
881
1002
  const n = parseFloat(val)
882
1003
  if (n > 0) {
883
1004
  api.kv.set(`${KV_PREFIX}.rate`, n)
884
- setExchangeRate(n)
1005
+ signals.setExchangeRate(n)
885
1006
  api.ui.toast({ message: `Exchange rate set to ${n}` })
886
1007
  }
887
1008
  dialog?.clear()
@@ -913,15 +1034,15 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
913
1034
  if (opt.value === "border") {
914
1035
  const cur = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
915
1036
  api.kv.set(`${KV_PREFIX}.border`, !cur)
916
- setBorderVisible(!cur)
1037
+ signals.setBorderVisible(!cur)
917
1038
  api.ui.toast({ message: `Panel border ${!cur ? "shown" : "hidden"}` })
918
1039
  } else {
919
1040
  const key = `${KV_PREFIX}.section.${opt.value}`
920
1041
  const cur = Boolean(api.kv.get(key, true))
921
1042
  api.kv.set(key, !cur)
922
- if (opt.value === "detail") setSectionDetail(!cur)
923
- if (opt.value === "model") setSectionModel(!cur)
924
- if (opt.value === "dist") setSectionDist(!cur)
1043
+ if (opt.value === "detail") signals.setSectionDetail(!cur)
1044
+ if (opt.value === "model") signals.setSectionModel(!cur)
1045
+ if (opt.value === "dist") signals.setSectionDist(!cur)
925
1046
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
926
1047
  }
927
1048
  dialog?.clear()