opencode-visual-cache 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.6.1";
1
+ export declare const PLUGIN_VERSION = "1.6.2";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.6.1";
2
+ export const PLUGIN_VERSION = "1.6.2";
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, createEffect, onMount, onCleanup, Show, untrack } from "solid-js";
2
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js";
3
3
  import { PLUGIN_VERSION } from "./_version";
4
4
  import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider } from "./balance-providers";
5
5
  import { LANG_META, createT, detectLang } from "./i18n";
@@ -333,6 +333,11 @@ function TokenCachePanel(props) {
333
333
  const [distOpen, setDistOpen] = createSignal(false);
334
334
  const [skillsOpen, setSkillsOpen] = createSignal(true);
335
335
  let boxEl;
336
+ // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
337
+ createEffect(() => {
338
+ props.signals.setSidebarVisible(true);
339
+ onCleanup(() => props.signals.setSidebarVisible(false));
340
+ });
336
341
  // ── shared signals (de-structured so internal code is unchanged) ──
337
342
  const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langCode, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, balanceProviderId, setBalanceProviderId, autoBalance, setAutoBalance, balanceUnsupported, setBalanceUnsupported, balanceState, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals;
338
343
  // ── reactive translation (follows langCode signal) ──
@@ -857,6 +862,32 @@ function TokenCachePanel(props) {
857
862
  // ---------------------------------------------------------------------------
858
863
  // Plugin entry
859
864
  // ---------------------------------------------------------------------------
865
+ /**
866
+ * 路径截断的固定开销(列数):仅宿主布局常量,不含任何内容宽度——
867
+ * 统计宽度、宿主 usage、commands 快捷键均为运行时动态计算。
868
+ * - marginLeft=1 + space-between 余量 ≈ 2 列
869
+ */
870
+ const PATH_CHROME = 2;
871
+ /**
872
+ * 路径可用宽度低于此列数时整体隐藏(极窄终端下路径信息价值太低,
873
+ * 残留的 "E:\Work…" 反而挤压右侧统计与 commands,直接让位更干净)。
874
+ */
875
+ const HIDE_PATH_BELOW = 14;
876
+ /**
877
+ * 从宿主 keymap 动态读取命令的快捷键显示文本(与宿主 Prompt 同源),
878
+ * 取不到时回退到传入的默认文本。
879
+ */
880
+ function keyShortcut(api, command, fallback) {
881
+ try {
882
+ const binds = api.tuiConfig.keybinds.get(command);
883
+ const seq = binds?.map((b) => ({ key: b.key }));
884
+ const s = api.keys.formatBindings(seq);
885
+ return s || fallback;
886
+ }
887
+ catch {
888
+ return fallback;
889
+ }
890
+ }
860
891
  /**
861
892
  * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
862
893
  * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
@@ -1000,6 +1031,122 @@ function BottomStatusBar(props) {
1000
1031
  return "";
1001
1032
  }
1002
1033
  });
1034
+ const [termW, setTermW] = createSignal(props.api.renderer.terminalWidth);
1035
+ // resize 事件(主通道);事件接口若在插件环境不可用则跳过,由轮询兜底
1036
+ createEffect(() => {
1037
+ const r = props.api.renderer;
1038
+ if (typeof r.on !== "function" || typeof r.off !== "function")
1039
+ return;
1040
+ const onResize = () => setTermW(props.api.renderer.terminalWidth);
1041
+ r.on("resize", onResize);
1042
+ onCleanup(() => r.off("resize", onResize));
1043
+ });
1044
+ // 轮询兜底:事件通道若在插件环境不可用,定期同步终端宽度(值不变时不触发更新)
1045
+ createEffect(() => {
1046
+ const timer = setInterval(() => setTermW(props.api.renderer.terminalWidth), 500);
1047
+ onCleanup(() => clearInterval(timer));
1048
+ });
1049
+ // 统计部分分段(单一数据源):量宽拼接 text,渲染逐段着色,避免双源漂移
1050
+ const statsSegs = createMemo(() => {
1051
+ const s = stats();
1052
+ const hr = s && s.hitRate >= 0 ? (Math.floor(s.hitRate * 10) / 10).toFixed(1) + "%" : "--";
1053
+ const segs = [
1054
+ { text: t("barHit") + " ", color: pal().muted },
1055
+ { text: hr, color: hitColor() },
1056
+ ];
1057
+ const tr = trend();
1058
+ if (tr !== null) {
1059
+ segs.push({ text: " " + (tr > 0 ? "\u2191" : "\u2193") + Math.abs(tr).toFixed(1) + "%", color: tr > 0 ? pal().success : pal().error });
1060
+ }
1061
+ segs.push({ text: " \u00b7 " + t("barTok") + " ", color: pal().muted });
1062
+ segs.push({ text: s ? fmtCompact(s.input + s.read + s.write) : "--", color: pal().text });
1063
+ if (!props.signals.balanceUnsupported()) {
1064
+ segs.push({ text: " \u00b7 " + t("barBal") + " ", color: pal().muted });
1065
+ segs.push({ text: balanceText(), color: pal().text });
1066
+ }
1067
+ segs.push({ text: " \u00b7 ", color: pal().muted });
1068
+ return segs;
1069
+ });
1070
+ const statsW = createMemo(() => {
1071
+ let w = 0;
1072
+ for (const sg of statsSegs())
1073
+ w += visualWidth(sg.text);
1074
+ return w;
1075
+ });
1076
+ // 宿主右侧 usage 文本复刻(1.18.16 Prompt 口径):
1077
+ // 最后一条 output>0 的 assistant 消息 → tokens 合计格式化 + 模型 context limit 百分比 + session 累计费用
1078
+ const sessionCost = createMemo(() => {
1079
+ try {
1080
+ return num(props.api.state.session.get(sid)?.cost);
1081
+ }
1082
+ catch {
1083
+ return 0;
1084
+ }
1085
+ });
1086
+ const usageText = createMemo(() => {
1087
+ const id = sid;
1088
+ if (!id)
1089
+ return "";
1090
+ const msgs = props.api.state.session.messages(id);
1091
+ let last;
1092
+ for (let i = msgs.length - 1; i >= 0; i--) {
1093
+ const m = msgs[i];
1094
+ if (m.role !== "assistant")
1095
+ continue;
1096
+ const tk = m.tokens;
1097
+ if (tk && num(tk.output) > 0) {
1098
+ last = m;
1099
+ break;
1100
+ }
1101
+ }
1102
+ if (!last)
1103
+ return "";
1104
+ const tk = last.tokens;
1105
+ if (!tk)
1106
+ return "";
1107
+ const tokens = num(tk.input) + num(tk.output) + num(tk.reasoning) + num(tk.cache?.read) + num(tk.cache?.write);
1108
+ if (tokens <= 0)
1109
+ return "";
1110
+ let pct = "";
1111
+ try {
1112
+ const p = props.api.state.provider.find((x) => x.id === last.providerID);
1113
+ const limit = p?.models?.[last.modelID]?.limit?.context;
1114
+ if (typeof limit === "number" && limit > 0)
1115
+ pct = ` (${Math.round((tokens / limit) * 100)}%)`;
1116
+ }
1117
+ catch { }
1118
+ const context = fmtCompact(tokens) + pct;
1119
+ const cost = sessionCost();
1120
+ return cost > 0 ? context + " \u00b7 " + fmtCost(cost) : context;
1121
+ });
1122
+ // 宿主 1513 行右侧文本:usage(有数据)或 "快捷键 agents"(无数据)+ commands,
1123
+ // 快捷键从宿主 keymap 动态读取
1124
+ const rightText = createMemo(() => {
1125
+ const cmds = keyShortcut(props.api, "command.palette.show", "ctrl+p") + " commands";
1126
+ const u = usageText();
1127
+ if (u)
1128
+ return u + " " + cmds;
1129
+ return keyShortcut(props.api, "agent.cycle", "") + " agents " + cmds;
1130
+ });
1131
+ const rightW = createMemo(() => visualWidth(rightText()));
1132
+ // 输入框实际宽度 = 终端宽度 - 侧边栏(可见时 42) - 边距 4
1133
+ // (与宿主 session 布局 contentWidth 口径一致;侧边栏可见性由本面板挂载状态驱动)
1134
+ const inputW = createMemo(() => termW() - (props.signals.sidebarVisible() ? 42 : 0) - 4);
1135
+ // 路径可用宽度 = 输入框宽度 - 统计宽度(精确) - 宿主右侧宽度(动态) - 布局开销;
1136
+ // 低于 HIDE_PATH_BELOW 时整体隐藏路径(宽度归零),把空间让给统计与 commands
1137
+ const dirDisplay = createMemo(() => {
1138
+ const avail = inputW() - statsW() - rightW() - PATH_CHROME;
1139
+ if (avail < HIDE_PATH_BELOW)
1140
+ return "";
1141
+ return truncateVisual(directory(), avail);
1142
+ });
1143
+ // 状态栏关闭(仅路径)时同样在极窄条件下隐藏路径
1144
+ const dirFallback = createMemo(() => {
1145
+ const avail = inputW() - rightW() - PATH_CHROME;
1146
+ if (avail < HIDE_PATH_BELOW)
1147
+ return "";
1148
+ return truncateVisual(directory(), avail);
1149
+ });
1003
1150
  // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
1004
1151
  onMount(() => {
1005
1152
  try {
@@ -1008,7 +1155,7 @@ function BottomStatusBar(props) {
1008
1155
  }
1009
1156
  catch { }
1010
1157
  });
1011
- return (_jsx(Show, { when: props.signals.sectionBottom(), fallback: _jsx("text", { fg: pal().muted, children: directory() }), children: _jsxs("box", { marginLeft: 1, flexGrow: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: pal().muted, children: directory() }), _jsxs("box", { flexDirection: "row", children: [_jsxs("text", { children: [_jsxs("span", { style: { fg: pal().muted }, children: [t("barHit"), " "] }), _jsx("span", { style: { fg: hitColor() }, children: (stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats().hitRate * 10) / 10).toFixed(1) + "%" : "--" }), _jsx(Show, { when: trend() !== null, children: _jsx("span", { style: { fg: trend() > 0 ? pal().success : pal().error }, children: " " + (trend() > 0 ? "\u2191" : "\u2193") + Math.abs(trend()).toFixed(1) + "%" }) }), _jsx("span", { style: { fg: pal().muted }, children: " \u00b7 " + t("barTok") + " " }), _jsx("span", { style: { fg: pal().text }, children: stats() ? fmtCompact(stats().input + stats().read + stats().write) : "--" }), _jsxs(Show, { when: !props.signals.balanceUnsupported(), children: [_jsx("span", { style: { fg: pal().muted }, children: " \u00b7 " + t("barBal") + " " }), _jsx("span", { style: { fg: pal().text }, children: balanceText() })] })] }), _jsx("text", { fg: pal().muted, children: " \u00b7 " })] })] }) }));
1158
+ return (_jsx(Show, { when: props.signals.sectionBottom(), fallback: _jsx("text", { fg: pal().muted, children: dirFallback() }), children: _jsxs("box", { marginLeft: 1, flexGrow: 1, flexShrink: 0, flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: pal().muted, children: dirDisplay() }), _jsx("box", { flexDirection: "row", children: _jsx("text", { children: _jsx(For, { each: statsSegs(), children: (sg) => _jsx("span", { style: { fg: sg.color }, children: sg.text }) }) }) })] }) }));
1012
1159
  }
1013
1160
  function createSidebarSlot(api, signals) {
1014
1161
  let lastSlotSid = "";
@@ -1047,6 +1194,8 @@ const tui = async (api) => {
1047
1194
  const [borderVisible, setBorderVisible] = createSignal(true);
1048
1195
  const [langCode, setLangCode] = createSignal(INIT_LANG);
1049
1196
  const [overrideSessionId, setOverrideSessionId] = createSignal(undefined);
1197
+ // 侧边栏可见性(由 TokenCachePanel 挂载状态驱动):可见时宿主输入框宽度 = 终端宽 - 42 - 4
1198
+ const [sidebarVisible, setSidebarVisible] = createSignal(false);
1050
1199
  // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1051
1200
  // 避免重复请求导致两处余额不一致 ──
1052
1201
  const [balanceState, setBalanceState] = createSignal({
@@ -1072,6 +1221,7 @@ const tui = async (api) => {
1072
1221
  balanceCurrency, setBalanceCurrency,
1073
1222
  borderVisible, setBorderVisible,
1074
1223
  overrideSessionId, setOverrideSessionId,
1224
+ sidebarVisible, setSidebarVisible,
1075
1225
  };
1076
1226
  api.slots.register(createSidebarSlot(api, signals));
1077
1227
  // 输入框 hint 行(session_prompt slot,replace 模式):
package/dist/tui.js CHANGED
@@ -10,10 +10,10 @@ import { spread as _$spread } from "@opentui/solid";
10
10
  import { mergeProps as _$mergeProps } from "@opentui/solid";
11
11
  import { memo as _$memo } from "@opentui/solid";
12
12
  import { createElement as _$createElement } from "@opentui/solid";
13
- import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js";
13
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js";
14
14
 
15
15
  // src/_version.ts
16
- var PLUGIN_VERSION = "1.6.1";
16
+ var PLUGIN_VERSION = "1.6.2";
17
17
 
18
18
  // src/balance-providers.ts
19
19
  var BalanceError = class extends Error {
@@ -707,6 +707,10 @@ function TokenCachePanel(props) {
707
707
  const [distOpen, setDistOpen] = createSignal(false);
708
708
  const [skillsOpen, setSkillsOpen] = createSignal(true);
709
709
  let boxEl;
710
+ createEffect(() => {
711
+ props.signals.setSidebarVisible(true);
712
+ onCleanup(() => props.signals.setSidebarVisible(false));
713
+ });
710
714
  const {
711
715
  currencySymbol,
712
716
  setCurrencySymbol,
@@ -1988,6 +1992,20 @@ function TokenCachePanel(props) {
1988
1992
  return _el$;
1989
1993
  })();
1990
1994
  }
1995
+ var PATH_CHROME = 2;
1996
+ var HIDE_PATH_BELOW = 14;
1997
+ function keyShortcut(api, command, fallback) {
1998
+ try {
1999
+ const binds = api.tuiConfig.keybinds.get(command);
2000
+ const seq = binds?.map((b) => ({
2001
+ key: b.key
2002
+ }));
2003
+ const s = api.keys.formatBindings(seq);
2004
+ return s || fallback;
2005
+ } catch {
2006
+ return fallback;
2007
+ }
2008
+ }
1991
2009
  function BottomStatusBar(props) {
1992
2010
  const KV_PREFIX = "cache_panel";
1993
2011
  const t = createT(() => props.signals.langCode());
@@ -2103,6 +2121,119 @@ function BottomStatusBar(props) {
2103
2121
  return "";
2104
2122
  }
2105
2123
  });
2124
+ const [termW, setTermW] = createSignal(props.api.renderer.terminalWidth);
2125
+ createEffect(() => {
2126
+ const r = props.api.renderer;
2127
+ if (typeof r.on !== "function" || typeof r.off !== "function") return;
2128
+ const onResize = () => setTermW(props.api.renderer.terminalWidth);
2129
+ r.on("resize", onResize);
2130
+ onCleanup(() => r.off("resize", onResize));
2131
+ });
2132
+ createEffect(() => {
2133
+ const timer = setInterval(() => setTermW(props.api.renderer.terminalWidth), 500);
2134
+ onCleanup(() => clearInterval(timer));
2135
+ });
2136
+ const statsSegs = createMemo(() => {
2137
+ const s = stats();
2138
+ const hr = s && s.hitRate >= 0 ? (Math.floor(s.hitRate * 10) / 10).toFixed(1) + "%" : "--";
2139
+ const segs = [{
2140
+ text: t("barHit") + " ",
2141
+ color: pal().muted
2142
+ }, {
2143
+ text: hr,
2144
+ color: hitColor()
2145
+ }];
2146
+ const tr = trend();
2147
+ if (tr !== null) {
2148
+ segs.push({
2149
+ text: " " + (tr > 0 ? "\u2191" : "\u2193") + Math.abs(tr).toFixed(1) + "%",
2150
+ color: tr > 0 ? pal().success : pal().error
2151
+ });
2152
+ }
2153
+ segs.push({
2154
+ text: " \xB7 " + t("barTok") + " ",
2155
+ color: pal().muted
2156
+ });
2157
+ segs.push({
2158
+ text: s ? fmtCompact(s.input + s.read + s.write) : "--",
2159
+ color: pal().text
2160
+ });
2161
+ if (!props.signals.balanceUnsupported()) {
2162
+ segs.push({
2163
+ text: " \xB7 " + t("barBal") + " ",
2164
+ color: pal().muted
2165
+ });
2166
+ segs.push({
2167
+ text: balanceText(),
2168
+ color: pal().text
2169
+ });
2170
+ }
2171
+ segs.push({
2172
+ text: " \xB7 ",
2173
+ color: pal().muted
2174
+ });
2175
+ return segs;
2176
+ });
2177
+ const statsW = createMemo(() => {
2178
+ let w = 0;
2179
+ for (const sg of statsSegs()) w += visualWidth(sg.text);
2180
+ return w;
2181
+ });
2182
+ const sessionCost = createMemo(() => {
2183
+ try {
2184
+ return num(props.api.state.session.get(sid)?.cost);
2185
+ } catch {
2186
+ return 0;
2187
+ }
2188
+ });
2189
+ const usageText = createMemo(() => {
2190
+ const id = sid;
2191
+ if (!id) return "";
2192
+ const msgs = props.api.state.session.messages(id);
2193
+ let last;
2194
+ for (let i = msgs.length - 1; i >= 0; i--) {
2195
+ const m = msgs[i];
2196
+ if (m.role !== "assistant") continue;
2197
+ const tk2 = m.tokens;
2198
+ if (tk2 && num(tk2.output) > 0) {
2199
+ last = m;
2200
+ break;
2201
+ }
2202
+ }
2203
+ if (!last) return "";
2204
+ const tk = last.tokens;
2205
+ if (!tk) return "";
2206
+ const tokens = num(tk.input) + num(tk.output) + num(tk.reasoning) + num(tk.cache?.read) + num(tk.cache?.write);
2207
+ if (tokens <= 0) return "";
2208
+ let pct = "";
2209
+ try {
2210
+ const p = props.api.state.provider.find((x) => x.id === last.providerID);
2211
+ const limit = p?.models?.[last.modelID]?.limit?.context;
2212
+ if (typeof limit === "number" && limit > 0) pct = ` (${Math.round(tokens / limit * 100)}%)`;
2213
+ } catch {
2214
+ }
2215
+ const context = fmtCompact(tokens) + pct;
2216
+ const cost = sessionCost();
2217
+ return cost > 0 ? context + " \xB7 " + fmtCost(cost) : context;
2218
+ });
2219
+ const rightText = createMemo(() => {
2220
+ const cmds = keyShortcut(props.api, "command.palette.show", "ctrl+p") + " commands";
2221
+ const u = usageText();
2222
+ if (u) return u + " " + cmds;
2223
+ return keyShortcut(props.api, "agent.cycle", "") + " agents " + cmds;
2224
+ });
2225
+ const rightW = createMemo(() => visualWidth(rightText()));
2226
+ const inputW = createMemo(() => termW() - (props.signals.sidebarVisible() ? 42 : 0) - 4);
2227
+ const dirDisplay = createMemo(() => {
2228
+ const avail = inputW() - statsW() - rightW() - PATH_CHROME;
2229
+ if (avail < HIDE_PATH_BELOW) return "";
2230
+ return truncateVisual(directory(), avail);
2231
+ });
2232
+ const dirFallback = createMemo(() => {
2233
+ const avail = inputW() - rightW() - PATH_CHROME;
2234
+ if (avail < HIDE_PATH_BELOW) return "";
2235
+ return truncateVisual(directory(), avail);
2236
+ });
2106
2237
  onMount(() => {
2107
2238
  try {
2108
2239
  const v = props.api.kv.get(`${KV_PREFIX}.section.bottom`, true);
@@ -2116,14 +2247,14 @@ function BottomStatusBar(props) {
2116
2247
  },
2117
2248
  get fallback() {
2118
2249
  return (() => {
2119
- var _el$111 = _$createElement("text");
2120
- _$insert(_el$111, directory);
2121
- _$effect((_$p) => _$setProp(_el$111, "fg", pal().muted, _$p));
2122
- return _el$111;
2250
+ var _el$101 = _$createElement("text");
2251
+ _$insert(_el$101, dirFallback);
2252
+ _$effect((_$p) => _$setProp(_el$101, "fg", pal().muted, _$p));
2253
+ return _el$101;
2123
2254
  })();
2124
2255
  },
2125
2256
  get children() {
2126
- var _el$97 = _$createElement("box"), _el$98 = _$createElement("text"), _el$99 = _$createElement("box"), _el$100 = _$createElement("text"), _el$101 = _$createElement("span"), _el$102 = _$createTextNode(` `), _el$103 = _$createElement("span"), _el$105 = _$createElement("span"), _el$106 = _$createElement("span"), _el$109 = _$createElement("text");
2257
+ var _el$97 = _$createElement("box"), _el$98 = _$createElement("text"), _el$99 = _$createElement("box"), _el$100 = _$createElement("text");
2127
2258
  _$insertNode(_el$97, _el$98);
2128
2259
  _$insertNode(_el$97, _el$99);
2129
2260
  _$setProp(_el$97, "marginLeft", 1);
@@ -2131,86 +2262,23 @@ function BottomStatusBar(props) {
2131
2262
  _$setProp(_el$97, "flexShrink", 0);
2132
2263
  _$setProp(_el$97, "flexDirection", "row");
2133
2264
  _$setProp(_el$97, "justifyContent", "space-between");
2134
- _$insert(_el$98, directory);
2265
+ _$insert(_el$98, dirDisplay);
2135
2266
  _$insertNode(_el$99, _el$100);
2136
- _$insertNode(_el$99, _el$109);
2137
2267
  _$setProp(_el$99, "flexDirection", "row");
2138
- _$insertNode(_el$100, _el$101);
2139
- _$insertNode(_el$100, _el$103);
2140
- _$insertNode(_el$100, _el$105);
2141
- _$insertNode(_el$100, _el$106);
2142
- _$insertNode(_el$101, _el$102);
2143
- _$insert(_el$101, () => t("barHit"), _el$102);
2144
- _$insert(_el$103, (() => {
2145
- var _c$ = _$memo(() => (stats()?.hitRate ?? -1) >= 0);
2146
- return () => _c$() ? (Math.floor(stats().hitRate * 10) / 10).toFixed(1) + "%" : "--";
2147
- })());
2148
- _$insert(_el$100, _$createComponent(Show, {
2149
- get when() {
2150
- return trend() !== null;
2268
+ _$insert(_el$100, _$createComponent(For, {
2269
+ get each() {
2270
+ return statsSegs();
2151
2271
  },
2152
- get children() {
2153
- var _el$104 = _$createElement("span");
2154
- _$insert(_el$104, () => " " + (trend() > 0 ? "\u2191" : "\u2193") + Math.abs(trend()).toFixed(1) + "%");
2155
- _$effect((_$p) => _$setProp(_el$104, "style", {
2156
- fg: trend() > 0 ? pal().success : pal().error
2272
+ children: (sg) => (() => {
2273
+ var _el$102 = _$createElement("span");
2274
+ _$insert(_el$102, () => sg.text);
2275
+ _$effect((_$p) => _$setProp(_el$102, "style", {
2276
+ fg: sg.color
2157
2277
  }, _$p));
2158
- return _el$104;
2159
- }
2160
- }), _el$105);
2161
- _$insert(_el$105, () => " \xB7 " + t("barTok") + " ");
2162
- _$insert(_el$106, (() => {
2163
- var _c$2 = _$memo(() => !!stats());
2164
- return () => _c$2() ? fmtCompact(stats().input + stats().read + stats().write) : "--";
2165
- })());
2166
- _$insert(_el$100, _$createComponent(Show, {
2167
- get when() {
2168
- return !props.signals.balanceUnsupported();
2169
- },
2170
- get children() {
2171
- return [(() => {
2172
- var _el$107 = _$createElement("span");
2173
- _$insert(_el$107, () => " \xB7 " + t("barBal") + " ");
2174
- _$effect((_$p) => _$setProp(_el$107, "style", {
2175
- fg: pal().muted
2176
- }, _$p));
2177
- return _el$107;
2178
- })(), (() => {
2179
- var _el$108 = _$createElement("span");
2180
- _$insert(_el$108, balanceText);
2181
- _$effect((_$p) => _$setProp(_el$108, "style", {
2182
- fg: pal().text
2183
- }, _$p));
2184
- return _el$108;
2185
- })()];
2186
- }
2187
- }), null);
2188
- _$insertNode(_el$109, _$createTextNode(` \xB7 `));
2189
- _$effect((_p$) => {
2190
- var _v$31 = pal().muted, _v$32 = {
2191
- fg: pal().muted
2192
- }, _v$33 = {
2193
- fg: hitColor()
2194
- }, _v$34 = {
2195
- fg: pal().muted
2196
- }, _v$35 = {
2197
- fg: pal().text
2198
- }, _v$36 = pal().muted;
2199
- _v$31 !== _p$.e && (_p$.e = _$setProp(_el$98, "fg", _v$31, _p$.e));
2200
- _v$32 !== _p$.t && (_p$.t = _$setProp(_el$101, "style", _v$32, _p$.t));
2201
- _v$33 !== _p$.a && (_p$.a = _$setProp(_el$103, "style", _v$33, _p$.a));
2202
- _v$34 !== _p$.o && (_p$.o = _$setProp(_el$105, "style", _v$34, _p$.o));
2203
- _v$35 !== _p$.i && (_p$.i = _$setProp(_el$106, "style", _v$35, _p$.i));
2204
- _v$36 !== _p$.n && (_p$.n = _$setProp(_el$109, "fg", _v$36, _p$.n));
2205
- return _p$;
2206
- }, {
2207
- e: void 0,
2208
- t: void 0,
2209
- a: void 0,
2210
- o: void 0,
2211
- i: void 0,
2212
- n: void 0
2213
- });
2278
+ return _el$102;
2279
+ })()
2280
+ }));
2281
+ _$effect((_$p) => _$setProp(_el$98, "fg", pal().muted, _$p));
2214
2282
  return _el$97;
2215
2283
  }
2216
2284
  });
@@ -2259,6 +2327,7 @@ var tui = async (api) => {
2259
2327
  const [borderVisible, setBorderVisible] = createSignal(true);
2260
2328
  const [langCode, setLangCode] = createSignal(INIT_LANG);
2261
2329
  const [overrideSessionId, setOverrideSessionId] = createSignal(void 0);
2330
+ const [sidebarVisible, setSidebarVisible] = createSignal(false);
2262
2331
  const [balanceState, setBalanceState] = createSignal({
2263
2332
  status: "idle",
2264
2333
  data: null,
@@ -2298,7 +2367,9 @@ var tui = async (api) => {
2298
2367
  borderVisible,
2299
2368
  setBorderVisible,
2300
2369
  overrideSessionId,
2301
- setOverrideSessionId
2370
+ setOverrideSessionId,
2371
+ sidebarVisible,
2372
+ setSidebarVisible
2302
2373
  };
2303
2374
  api.slots.register(createSidebarSlot(api, signals));
2304
2375
  api.slots.register({
@@ -2441,11 +2512,11 @@ var tui = async (api) => {
2441
2512
  return provider.name;
2442
2513
  },
2443
2514
  description: () => (() => {
2444
- var _el$112 = _$createElement("text");
2445
- _$insert(_el$112, () => t("balKeyPrompt", {
2515
+ var _el$103 = _$createElement("text");
2516
+ _$insert(_el$103, () => t("balKeyPrompt", {
2446
2517
  p: provider.name
2447
2518
  }));
2448
- return _el$112;
2519
+ return _el$103;
2449
2520
  })(),
2450
2521
  get placeholder() {
2451
2522
  return provider.keyPlaceholder ?? "sk-...";
@@ -2525,9 +2596,9 @@ var tui = async (api) => {
2525
2596
  dialog?.replace(() => _$createComponent(api.ui.DialogPrompt, {
2526
2597
  title: "Exchange Rate",
2527
2598
  description: () => (() => {
2528
- var _el$113 = _$createElement("text");
2529
- _$insertNode(_el$113, _$createTextNode(`Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)`));
2530
- return _el$113;
2599
+ var _el$104 = _$createElement("text");
2600
+ _$insertNode(_el$104, _$createTextNode(`Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)`));
2601
+ return _el$104;
2531
2602
  })(),
2532
2603
  placeholder: "1.0",
2533
2604
  get value() {
@@ -2939,9 +3010,9 @@ var tui = async (api) => {
2939
3010
  return _$memo(() => !!signals.overrideSessionId())() ? t("subSwitchTitle") : t("subViewTitle");
2940
3011
  },
2941
3012
  description: () => (() => {
2942
- var _el$115 = _$createElement("text");
2943
- _$insert(_el$115, () => t("subNoFound"));
2944
- return _el$115;
3013
+ var _el$106 = _$createElement("text");
3014
+ _$insert(_el$106, () => t("subNoFound"));
3015
+ return _el$106;
2945
3016
  })(),
2946
3017
  placeholder: "ses_...",
2947
3018
  get value() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "types": "dist/index.d.ts",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.6.1";
2
+ export const PLUGIN_VERSION="1.6.2";
package/src/index.tsx CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  TuiThemeCurrent,
11
11
  TuiDialogStack,
12
12
  TuiPromptRef,
13
+ SequenceBindingLike,
13
14
  } from "@opencode-ai/plugin/tui"
14
15
  import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk"
15
16
  import type {
@@ -19,7 +20,7 @@ import type {
19
20
  FilePart,
20
21
  ReasoningPart,
21
22
  } from "@opencode-ai/sdk/v2"
22
- import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js"
23
+ import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, For, untrack } from "solid-js"
23
24
  import { PLUGIN_VERSION } from "./_version"
24
25
  import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers"
25
26
  import { LANG_META, createT, detectLang, type LangCode } from "./i18n"
@@ -404,6 +405,9 @@ interface PanelSignals {
404
405
  /** When set, the panel renders stats for this session instead of the main one. */
405
406
  overrideSessionId: () => string | undefined
406
407
  setOverrideSessionId: (v: string | undefined) => void
408
+ /** True while our sidebar panel is mounted — host sidebar is visible (occupies 42 cols). */
409
+ sidebarVisible: () => boolean
410
+ setSidebarVisible: (v: boolean) => void
407
411
  }
408
412
 
409
413
  const CURRENCIES: Record<string, string> = {
@@ -441,6 +445,12 @@ function TokenCachePanel(props: {
441
445
  const [skillsOpen, setSkillsOpen] = createSignal(true)
442
446
  let boxEl: any
443
447
 
448
+ // 侧边栏可见性通知:本面板挂载 ⇒ 宿主侧边栏可见(固定占用 42 列输入框宽度)
449
+ createEffect(() => {
450
+ props.signals.setSidebarVisible(true)
451
+ onCleanup(() => props.signals.setSidebarVisible(false))
452
+ })
453
+
444
454
  // ── shared signals (de-structured so internal code is unchanged) ──
445
455
  const {
446
456
  currencySymbol, setCurrencySymbol,
@@ -1178,6 +1188,33 @@ function TokenCachePanel(props: {
1178
1188
  // Plugin entry
1179
1189
  // ---------------------------------------------------------------------------
1180
1190
 
1191
+ /**
1192
+ * 路径截断的固定开销(列数):仅宿主布局常量,不含任何内容宽度——
1193
+ * 统计宽度、宿主 usage、commands 快捷键均为运行时动态计算。
1194
+ * - marginLeft=1 + space-between 余量 ≈ 2 列
1195
+ */
1196
+ const PATH_CHROME = 2
1197
+ /**
1198
+ * 路径可用宽度低于此列数时整体隐藏(极窄终端下路径信息价值太低,
1199
+ * 残留的 "E:\Work…" 反而挤压右侧统计与 commands,直接让位更干净)。
1200
+ */
1201
+ const HIDE_PATH_BELOW = 14
1202
+
1203
+ /**
1204
+ * 从宿主 keymap 动态读取命令的快捷键显示文本(与宿主 Prompt 同源),
1205
+ * 取不到时回退到传入的默认文本。
1206
+ */
1207
+ function keyShortcut(api: TuiPluginApi, command: string, fallback: string): string {
1208
+ try {
1209
+ const binds = api.tuiConfig.keybinds.get(command)
1210
+ const seq = binds?.map((b) => ({ key: b.key }))
1211
+ const s = api.keys.formatBindings(seq as unknown as SequenceBindingLike[])
1212
+ return s || fallback
1213
+ } catch {
1214
+ return fallback
1215
+ }
1216
+ }
1217
+
1181
1218
  /**
1182
1219
  * 输入框 hint 行(session_prompt slot 的 hint):单行显示 路径 · 命中率 · 余额 · Tokens。
1183
1220
  * 通过 ui.Prompt 的 hint prop 注入——宿主右侧的 token/commands 提示自动保留,
@@ -1301,6 +1338,116 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess
1301
1338
  try { return props.api.state.path.directory } catch { return "" }
1302
1339
  })
1303
1340
 
1341
+ // 终端宽度信号:初始读取渲染器,窗口 resize 时更新(宿主不约束 hint 行宽度,
1342
+ // 路径截断必须基于终端宽度手动计算)。
1343
+ // CliRenderer 继承的 EventEmitter 因项目未装 @types/node 类型不可见,
1344
+ // 用最小接口声明补齐 resize 事件的 on/off。
1345
+ interface ResizeEmitter {
1346
+ on(event: "resize", cb: () => void): unknown
1347
+ off(event: "resize", cb: () => void): unknown
1348
+ }
1349
+ const [termW, setTermW] = createSignal(props.api.renderer.terminalWidth)
1350
+ // resize 事件(主通道);事件接口若在插件环境不可用则跳过,由轮询兜底
1351
+ createEffect(() => {
1352
+ const r = props.api.renderer as unknown as ResizeEmitter
1353
+ if (typeof r.on !== "function" || typeof r.off !== "function") return
1354
+ const onResize = () => setTermW(props.api.renderer.terminalWidth)
1355
+ r.on("resize", onResize)
1356
+ onCleanup(() => r.off("resize", onResize))
1357
+ })
1358
+ // 轮询兜底:事件通道若在插件环境不可用,定期同步终端宽度(值不变时不触发更新)
1359
+ createEffect(() => {
1360
+ const timer = setInterval(() => setTermW(props.api.renderer.terminalWidth), 500)
1361
+ onCleanup(() => clearInterval(timer))
1362
+ })
1363
+
1364
+ // 统计部分分段(单一数据源):量宽拼接 text,渲染逐段着色,避免双源漂移
1365
+ const statsSegs = createMemo<{ text: string; color: string | undefined }[]>(() => {
1366
+ const s = stats()
1367
+ const hr = s && s.hitRate >= 0 ? (Math.floor(s.hitRate * 10) / 10).toFixed(1) + "%" : "--"
1368
+ const segs: { text: string; color: string | undefined }[] = [
1369
+ { text: t("barHit") + " ", color: pal().muted },
1370
+ { text: hr, color: hitColor() },
1371
+ ]
1372
+ const tr = trend()
1373
+ if (tr !== null) {
1374
+ segs.push({ text: " " + (tr > 0 ? "\u2191" : "\u2193") + Math.abs(tr).toFixed(1) + "%", color: tr > 0 ? pal().success : pal().error })
1375
+ }
1376
+ segs.push({ text: " \u00b7 " + t("barTok") + " ", color: pal().muted })
1377
+ segs.push({ text: s ? fmtCompact(s.input + s.read + s.write) : "--", color: pal().text })
1378
+ if (!props.signals.balanceUnsupported()) {
1379
+ segs.push({ text: " \u00b7 " + t("barBal") + " ", color: pal().muted })
1380
+ segs.push({ text: balanceText(), color: pal().text })
1381
+ }
1382
+ segs.push({ text: " \u00b7 ", color: pal().muted })
1383
+ return segs
1384
+ })
1385
+ const statsW = createMemo(() => {
1386
+ let w = 0
1387
+ for (const sg of statsSegs()) w += visualWidth(sg.text)
1388
+ return w
1389
+ })
1390
+
1391
+ // 宿主右侧 usage 文本复刻(1.18.16 Prompt 口径):
1392
+ // 最后一条 output>0 的 assistant 消息 → tokens 合计格式化 + 模型 context limit 百分比 + session 累计费用
1393
+ const sessionCost = createMemo(() => {
1394
+ try { return num(props.api.state.session.get(sid)?.cost) } catch { return 0 }
1395
+ })
1396
+ const usageText = createMemo(() => {
1397
+ const id = sid
1398
+ if (!id) return ""
1399
+ const msgs = props.api.state.session.messages(id) as Message[]
1400
+ let last: AssistantMessage | undefined
1401
+ for (let i = msgs.length - 1; i >= 0; i--) {
1402
+ const m = msgs[i]
1403
+ if (m.role !== "assistant") continue
1404
+ const tk = (m as AssistantMessage).tokens
1405
+ if (tk && num(tk.output) > 0) { last = m as AssistantMessage; break }
1406
+ }
1407
+ if (!last) return ""
1408
+ const tk = last.tokens
1409
+ if (!tk) return ""
1410
+ const tokens = num(tk.input) + num(tk.output) + num(tk.reasoning) + num(tk.cache?.read) + num(tk.cache?.write)
1411
+ if (tokens <= 0) return ""
1412
+ let pct = ""
1413
+ try {
1414
+ const p = props.api.state.provider.find((x) => x.id === last.providerID)
1415
+ const limit = p?.models?.[last.modelID]?.limit?.context
1416
+ if (typeof limit === "number" && limit > 0) pct = ` (${Math.round((tokens / limit) * 100)}%)`
1417
+ } catch {}
1418
+ const context = fmtCompact(tokens) + pct
1419
+ const cost = sessionCost()
1420
+ return cost > 0 ? context + " \u00b7 " + fmtCost(cost) : context
1421
+ })
1422
+
1423
+ // 宿主 1513 行右侧文本:usage(有数据)或 "快捷键 agents"(无数据)+ commands,
1424
+ // 快捷键从宿主 keymap 动态读取
1425
+ const rightText = createMemo(() => {
1426
+ const cmds = keyShortcut(props.api, "command.palette.show", "ctrl+p") + " commands"
1427
+ const u = usageText()
1428
+ if (u) return u + " " + cmds
1429
+ return keyShortcut(props.api, "agent.cycle", "") + " agents " + cmds
1430
+ })
1431
+ const rightW = createMemo(() => visualWidth(rightText()))
1432
+
1433
+ // 输入框实际宽度 = 终端宽度 - 侧边栏(可见时 42) - 边距 4
1434
+ // (与宿主 session 布局 contentWidth 口径一致;侧边栏可见性由本面板挂载状态驱动)
1435
+ const inputW = createMemo(() => termW() - (props.signals.sidebarVisible() ? 42 : 0) - 4)
1436
+
1437
+ // 路径可用宽度 = 输入框宽度 - 统计宽度(精确) - 宿主右侧宽度(动态) - 布局开销;
1438
+ // 低于 HIDE_PATH_BELOW 时整体隐藏路径(宽度归零),把空间让给统计与 commands
1439
+ const dirDisplay = createMemo(() => {
1440
+ const avail = inputW() - statsW() - rightW() - PATH_CHROME
1441
+ if (avail < HIDE_PATH_BELOW) return ""
1442
+ return truncateVisual(directory(), avail)
1443
+ })
1444
+ // 状态栏关闭(仅路径)时同样在极窄条件下隐藏路径
1445
+ const dirFallback = createMemo(() => {
1446
+ const avail = inputW() - rightW() - PATH_CHROME
1447
+ if (avail < HIDE_PATH_BELOW) return ""
1448
+ return truncateVisual(directory(), avail)
1449
+ })
1450
+
1304
1451
  // 恢复显隐偏好(默认显示);关闭时回退为仅显示路径,与宿主默认 hint 行一致
1305
1452
  onMount(() => {
1306
1453
  try {
@@ -1310,28 +1457,18 @@ function BottomStatusBar(props: { api: TuiPluginApi; signals: PanelSignals; sess
1310
1457
  })
1311
1458
 
1312
1459
  return (
1313
- <Show when={props.signals.sectionBottom()} fallback={<text fg={pal().muted}>{directory()}</text>}>
1460
+ <Show
1461
+ when={props.signals.sectionBottom()}
1462
+ fallback={<text fg={pal().muted}>{dirFallback()}</text>}
1463
+ >
1314
1464
  <box marginLeft={1} flexGrow={1} flexShrink={0} flexDirection="row" justifyContent="space-between">
1315
- <text fg={pal().muted}>{directory()}</text>
1316
- <box flexDirection="row">
1465
+ <text fg={pal().muted}>{dirDisplay()}</text>
1466
+ <box flexDirection="row">
1317
1467
  <text>
1318
- <span style={{ fg: pal().muted }}>{t("barHit")} </span>
1319
- <span style={{ fg: hitColor() }}>{(stats()?.hitRate ?? -1) >= 0 ? (Math.floor(stats()!.hitRate * 10) / 10).toFixed(1) + "%" : "--"}</span>
1320
- <Show when={trend() !== null}>
1321
- <span style={{ fg: trend()! > 0 ? pal().success : pal().error }}>
1322
- {" " + (trend()! > 0 ? "\u2191" : "\u2193") + Math.abs(trend()!).toFixed(1) + "%"}
1323
- </span>
1324
- </Show>
1325
- <span style={{ fg: pal().muted }}>{" \u00b7 " + t("barTok") + " "}</span>
1326
- <span style={{ fg: pal().text }}>
1327
- {stats() ? fmtCompact(stats()!.input + stats()!.read + stats()!.write) : "--"}
1328
- </span>
1329
- <Show when={!props.signals.balanceUnsupported()}>
1330
- <span style={{ fg: pal().muted }}>{" \u00b7 " + t("barBal") + " "}</span>
1331
- <span style={{ fg: pal().text }}>{balanceText()}</span>
1332
- </Show>
1468
+ <For each={statsSegs()}>
1469
+ {(sg) => <span style={{ fg: sg.color }}>{sg.text}</span>}
1470
+ </For>
1333
1471
  </text>
1334
- <text fg={pal().muted}>{" \u00b7 "}</text>
1335
1472
  </box>
1336
1473
  </box>
1337
1474
  </Show>
@@ -1383,6 +1520,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1383
1520
  const [borderVisible, setBorderVisible] = createSignal(true)
1384
1521
  const [langCode, setLangCode] = createSignal<LangCode>(INIT_LANG)
1385
1522
  const [overrideSessionId, setOverrideSessionId] = createSignal<string | undefined>(undefined)
1523
+ // 侧边栏可见性(由 TokenCachePanel 挂载状态驱动):可见时宿主输入框宽度 = 终端宽 - 42 - 4
1524
+ const [sidebarVisible, setSidebarVisible] = createSignal(false)
1386
1525
 
1387
1526
  // ── 余额查询状态(共享):侧边栏与底部栏读同一份数据,
1388
1527
  // 避免重复请求导致两处余额不一致 ──
@@ -1410,6 +1549,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1410
1549
  balanceCurrency, setBalanceCurrency,
1411
1550
  borderVisible, setBorderVisible,
1412
1551
  overrideSessionId, setOverrideSessionId,
1552
+ sidebarVisible, setSidebarVisible,
1413
1553
  }
1414
1554
 
1415
1555
  api.slots.register(createSidebarSlot(api, signals))