opencode-visual-cache 1.2.10 → 1.2.12

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
@@ -44,9 +44,10 @@
44
44
  - **颜色自适应**:命中率 ≥85% 绿 · ≥70% 橙 · <70% 红,颜色从主题色自动去饱和
45
45
  - **Token 分布**:按角色(系统提示 / 用户 / Agent 指令 / Tool 调用 / Tool 结果)展示估算 Token 占比
46
46
  - **折叠记忆**:折叠状态持久化,重启后保持
47
- - **语言适配**:自动检测系统语言
47
+ - **语言适配**:自动检测系统语言,支持 `/cache-lang` 运行时切换中/英文,偏好持久化
48
48
  - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算
49
- - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` 动态配置面板
49
+ - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板
50
+ - **已加载技能**:检测 session 中 LLM 调用 `skill` tool 的记录,展示已加载技能名及估算 Token 占用
50
51
 
51
52
  ---
52
53
 
@@ -97,8 +98,9 @@ npm install -g opencode-visual-cache@latest
97
98
  |------|------|---------|
98
99
  | `/cache-currency` | 切换货币单位 | 从列表选择货币(USD / CNY / EUR / JPY / GBP / KRW),自动填入默认汇率 |
99
100
  | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 |
100
- | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 面板边框的显隐 |
101
+ | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 面板边框的显隐 |
101
102
  | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 |
103
+ | `/cache-lang` | 切换显示语言 | 从列表选择中文或 English,界面即时切换,无需重启 |
102
104
 
103
105
  <div align="center">
104
106
  <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="斜杠命令" width="49%"></img>
@@ -126,11 +128,12 @@ npm install -g opencode-visual-cache@latest
126
128
 
127
129
  ### 4.3 区块可见性
128
130
 
129
- 面板中的三个子区块可以独立关闭,方便在侧边栏空间紧张时隐藏不需要的信息:
131
+ 面板中的子区块可以独立关闭,方便在侧边栏空间紧张时隐藏不需要的信息:
130
132
 
131
133
  - **Token 明细**:缓存读 / 缓存写 / 未命中 / 输出
132
134
  - **模型与定价**:费用 / 提供商 / 模型名 / 单价
133
135
  - **估算 Token 分布**:按角色拆分的 Token 估算
136
+ - **已加载技能**:session 中 LLM 实际调用过的 Skill 名及估算 Token 占用
134
137
 
135
138
  通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。
136
139
 
@@ -164,9 +167,17 @@ rm -rf ~/.cache/opencode/packages/opencode-visual-cache@latest
164
167
 
165
168
  ---
166
169
 
167
- ## 6. 调试
170
+ ## 6. 语言设置
168
171
 
169
- 强制英文:
172
+ 插件支持三种方式控制显示语言,按优先级从高到低排列:
173
+
174
+ ### 6.1 运行时切换(推荐)
175
+
176
+ 在 TUI 中输入 `/cache-lang`,从弹窗选择「中文」或「English」即可即时切换,无需重启。偏好会自动持久化,下次启动自动恢复。
177
+
178
+ ### 6.2 环境变量覆盖
179
+
180
+ 启动前设置 `CACHE_TUI_LANG` 环境变量可强制指定语言:
170
181
 
171
182
  ```powershell
172
183
  # Windows PowerShell
@@ -178,6 +189,10 @@ $env:CACHE_TUI_LANG="en"; opencode
178
189
  CACHE_TUI_LANG=en opencode
179
190
  ```
180
191
 
192
+ ### 6.3 自动检测
193
+
194
+ 默认自动检测系统语言。若不符合预期,用 `/cache-lang` 手动切换一次即可,偏好会被记住。
195
+
181
196
  ---
182
197
 
183
198
  ## 7. 兼容性
package/README_EN.md CHANGED
@@ -44,9 +44,10 @@ If you find this plugin useful, a ⭐ would mean a lot — thank you!<br>
44
44
  - **Adaptive Colors**: ≥85% green · ≥70% orange · <70% red, auto-desaturated from current theme
45
45
  - **Token Distribution**: Per-role (system / user / agent instr / tool call / tool result) estimated token breakdown
46
46
  - **Persistent State**: Fold preferences and config remembered across restarts via api.kv
47
- - **Language**: Auto-detects system locale
47
+ - **Language**: Auto-detects system locale, with `/cache-lang` for runtime switching between Chinese and English — preference persisted across restarts
48
48
  - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time
49
- - **Slash Commands**: `/cache-rate` `/cache-section` `/cache-config` for live panel configuration
49
+ - **Slash Commands**: `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration
50
+ - **Loaded Skills**: Detects `skill` tool calls in the session and displays loaded skill names with estimated token footprint
50
51
 
51
52
  ---
52
53
 
@@ -97,8 +98,9 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime
97
98
  |---------|----------|------------|
98
99
  | `/cache-currency` | Switch currency | Pick from a list (USD / CNY / EUR / JPY / GBP / KRW); default exchange rate auto-filled |
99
100
  | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) |
100
- | `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, or the panel border |
101
+ | `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, Loaded Skills, or the panel border |
101
102
  | `/cache-config` | View current config | Displays currency, rate, and section visibility |
103
+ | `/cache-lang` | Switch display language | Pick Chinese or English from the dialog — takes effect immediately, no restart needed |
102
104
 
103
105
  <div align="center">
104
106
  <img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="Slash command" width="49%"></img>
@@ -131,6 +133,7 @@ Three sub-sections can be toggled independently to save sidebar space:
131
133
  - **Token Detail**: cache read / write / miss / output
132
134
  - **Model & Pricing**: cost / provider / model name / per-million rates
133
135
  - **Estimated Token Dist.**: per-role token breakdown
136
+ - **Loaded Skills**: skill names the LLM actually loaded via the `skill` tool, with estimated token counts
134
137
 
135
138
  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
139
 
@@ -162,9 +165,17 @@ Press **`Ctrl + P`** in OpenCode → `install plugin` → `opencode-visual-cache
162
165
 
163
166
  ---
164
167
 
165
- ## 6. Debug
168
+ ## 6. Language Settings
166
169
 
167
- Force English:
170
+ The plugin provides three ways to control the display language, listed by priority (highest first):
171
+
172
+ ### 6.1 Runtime Switching (recommended)
173
+
174
+ Type `/cache-lang` in the TUI and select **Chinese** or **English** from the dialog. The panel switches immediately without restarting, and your preference is persisted automatically for the next session.
175
+
176
+ ### 6.2 Environment Variable Override
177
+
178
+ Set the `CACHE_TUI_LANG` environment variable before launching to force a specific language:
168
179
 
169
180
  ```powershell
170
181
  # Windows PowerShell
@@ -176,6 +187,10 @@ $env:CACHE_TUI_LANG="en"; opencode
176
187
  CACHE_TUI_LANG=en opencode
177
188
  ```
178
189
 
190
+ ### 6.3 Auto Detection
191
+
192
+ Defaults to the system locale automatically. If it doesn't match, switch once with `/cache-lang` and the preference will be remembered.
193
+
179
194
  ---
180
195
 
181
196
  ## 7. Compatibility
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.10";
1
+ export declare const PLUGIN_VERSION = "1.2.12";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.10";
2
+ export const PLUGIN_VERSION = "1.2.12";
package/dist/index.js CHANGED
@@ -35,6 +35,22 @@ function visualPadEnd(s, cols) {
35
35
  const pad = cols - visualWidth(s);
36
36
  return pad > 0 ? s + " ".repeat(pad) : s;
37
37
  }
38
+ /** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
39
+ function truncateVisual(s, maxCols) {
40
+ if (visualWidth(s) <= maxCols)
41
+ return s;
42
+ let result = "", w = 0;
43
+ for (const c of s) {
44
+ const cw = charColumns(c);
45
+ if (w + cw > maxCols - 1) {
46
+ result += "\u2026";
47
+ break;
48
+ }
49
+ result += c;
50
+ w += cw;
51
+ }
52
+ return result;
53
+ }
38
54
  // ── language override (env: CACHE_TUI_LANG) ──
39
55
  const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined;
40
56
  // ── language ──────────────────────────────────────────────────────
@@ -48,7 +64,7 @@ const LANG_ZH = DEBUG_LANG
48
64
  return false;
49
65
  }
50
66
  })();
51
- const T = LANG_ZH ? {
67
+ const ZH_T = {
52
68
  title: "缓存统计",
53
69
  hit: "命中率",
54
70
  totalHit: "总命中:",
@@ -77,7 +93,9 @@ const T = LANG_ZH ? {
77
93
  distOut: "输出:",
78
94
  secDetail: "明细",
79
95
  secModel: "模型",
80
- } : {
96
+ secSkills: "已加载技能",
97
+ };
98
+ const EN_T = {
81
99
  title: "Token Cache",
82
100
  hit: "Hit",
83
101
  totalHit: "Total Hit:",
@@ -106,6 +124,7 @@ const T = LANG_ZH ? {
106
124
  distOut: "Output:",
107
125
  secDetail: "Detail",
108
126
  secModel: "Model",
127
+ secSkills: "Loaded Skills",
109
128
  };
110
129
  // ── color helpers ────────────────────────────────────────────────
111
130
  /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
@@ -293,9 +312,12 @@ function TokenCachePanel(props) {
293
312
  const [detailOpen, setDetailOpen] = createSignal(true);
294
313
  const [modelOpen, setModelOpen] = createSignal(true);
295
314
  const [distOpen, setDistOpen] = createSignal(false);
315
+ const [skillsOpen, setSkillsOpen] = createSignal(true);
296
316
  let boxEl;
297
317
  // ── shared signals (de-structured so internal code is unchanged) ──
298
- const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, borderVisible, setBorderVisible, } = props.signals;
318
+ const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, sectionDetail, setSectionDetail, sectionModel, setSectionModel, sectionDist, setSectionDist, sectionSkills, setSectionSkills, borderVisible, setBorderVisible, } = props.signals;
319
+ // ── reactive translation (follows langZH signal) ──
320
+ const t = createMemo(() => langZH() ? ZH_T : EN_T);
299
321
  // ── scan session messages reactively ──
300
322
  // SolidJS createMemo re-evaluates whenever the underlying
301
323
  // api.state.session state changes — no event listener needed.
@@ -316,6 +338,8 @@ function TokenCachePanel(props) {
316
338
  providerName: "", sessionHitRate: 0,
317
339
  dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
318
340
  hasDistData: false,
341
+ skills: [],
342
+ hasSkills: false,
319
343
  });
320
344
  const [refreshTick, setRefreshTick] = createSignal(0);
321
345
  createEffect(() => {
@@ -371,6 +395,7 @@ function TokenCachePanel(props) {
371
395
  const distData = untrack(() => {
372
396
  let dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 };
373
397
  let hasDistData = false;
398
+ const loadedSkills = new Map();
374
399
  try {
375
400
  const session = props.api.state.session.get(sid), cfg = props.api.state.config;
376
401
  const agentName = String(session?.agent ?? cfg?.default_agent ?? "build");
@@ -428,6 +453,25 @@ function TokenCachePanel(props) {
428
453
  if (e.error)
429
454
  dist.toolResult += estimateTokens(e.error);
430
455
  }
456
+ if (tp.tool === "skill" && tp.state.status === "completed") {
457
+ // TUI SDK strips tool metadata — extract skill name from well-known output format.
458
+ // Cross-validated against api.client.app.skills() when available.
459
+ let name = tp.state.metadata?.name;
460
+ if (typeof name !== "string") {
461
+ const m = typeof tp.state.output === "string"
462
+ ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
463
+ : null;
464
+ if (m)
465
+ name = m[1].trim();
466
+ }
467
+ if (typeof name === "string") {
468
+ const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0;
469
+ const existing = loadedSkills.get(name);
470
+ if (!existing || existing.tokens < tokens) {
471
+ loadedSkills.set(name, { name, tokens });
472
+ }
473
+ }
474
+ }
431
475
  }
432
476
  else if (p.type === "reasoning")
433
477
  dist.agent += estimateTokens(p.text);
@@ -455,7 +499,8 @@ function TokenCachePanel(props) {
455
499
  }
456
500
  catch { }
457
501
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist();
458
- return { finalDist, finalHasDist };
502
+ const skills = [...loadedSkills.values()];
503
+ return { finalDist, finalHasDist, skills };
459
504
  });
460
505
  setDataSignal({
461
506
  hitRate, read, write, freshInput: input, output, cost, saved, model,
@@ -463,6 +508,7 @@ function TokenCachePanel(props) {
463
508
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
464
509
  trend, hasTrendData, providerName, sessionHitRate,
465
510
  dist: distData.finalDist, hasDistData: distData.finalHasDist,
511
+ skills: distData.skills, hasSkills: distData.skills.length > 0,
466
512
  });
467
513
  });
468
514
  const data = createMemo(() => {
@@ -502,6 +548,7 @@ function TokenCachePanel(props) {
502
548
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)));
503
549
  setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)));
504
550
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
551
+ setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)));
505
552
  }
506
553
  catch { }
507
554
  // Restore user config (currency, rate, section visibility).
@@ -518,8 +565,14 @@ function TokenCachePanel(props) {
518
565
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
519
566
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
520
567
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
568
+ setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)));
521
569
  const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
522
570
  setBorderVisible(bv !== false);
571
+ // Restore language preference
572
+ const savedLang = props.api.kv.get(`${KV_PREFIX}.lang`);
573
+ if (savedLang === "zh" || savedLang === "en") {
574
+ setLangZH(savedLang === "zh");
575
+ }
523
576
  // Restore distribution snapshot so the token distribution block
524
577
  // doesn't blank out while api.state.part() re-hydrates.
525
578
  const cachedDist = props.api.kv.get(`${KV_PREFIX}.dist_snapshot`);
@@ -603,7 +656,7 @@ function TokenCachePanel(props) {
603
656
  }
604
657
  const barW = createMemo(() => {
605
658
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0;
606
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
659
+ const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter();
607
660
  return Math.max(3, panelWidth() - overhead);
608
661
  });
609
662
  const bar = createMemo(() => progressBar(data().hitRate, barW()));
@@ -629,7 +682,12 @@ function TokenCachePanel(props) {
629
682
  // boxEl.width may be undefined before the first measurement — guard with 0
630
683
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
631
684
  setPanelWidth((prev) => (prev === w ? prev : w));
632
- }, 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.apiInput), T.tok) })] })] }) })] }) })] }));
685
+ }, 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.apiInput), t().tok) })] })] }) }), _jsx(Show, { when: sectionSkills(), children: _jsxs(Show, { when: data().hasSkills, children: [_jsxs("text", { onMouseUp: () => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: skillsOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: t().secSkills }) }), _jsxs("span", { style: { fg: pal().muted }, children: [" (", data().skills.length, ")"] }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`)) })] }), _jsx(Show, { when: skillsOpen(), children: data().skills.map((sk) => {
686
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok);
687
+ const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
688
+ const label = truncateVisual(sk.name, maxLabel);
689
+ return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t().tok) }));
690
+ }) })] }) })] }) })] }));
633
691
  }
634
692
  // ---------------------------------------------------------------------------
635
693
  // Plugin entry
@@ -651,13 +709,17 @@ const tui = async (api) => {
651
709
  const [sectionDetail, setSectionDetail] = createSignal(true);
652
710
  const [sectionModel, setSectionModel] = createSignal(true);
653
711
  const [sectionDist, setSectionDist] = createSignal(true);
712
+ const [sectionSkills, setSectionSkills] = createSignal(true);
654
713
  const [borderVisible, setBorderVisible] = createSignal(true);
714
+ const [langZH, setLangZH] = createSignal(LANG_ZH);
655
715
  const signals = {
656
716
  currencySymbol, setCurrencySymbol,
657
717
  exchangeRate, setExchangeRate,
718
+ langZH, setLangZH,
658
719
  sectionDetail, setSectionDetail,
659
720
  sectionModel, setSectionModel,
660
721
  sectionDist, setSectionDist,
722
+ sectionSkills, setSectionSkills,
661
723
  borderVisible, setBorderVisible,
662
724
  };
663
725
  api.slots.register(createSidebarSlot(api, signals));
@@ -711,11 +773,13 @@ const tui = async (api) => {
711
773
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
712
774
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
713
775
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
776
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
714
777
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
715
778
  dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
716
779
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
717
780
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
718
781
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
782
+ { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
719
783
  { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
720
784
  ], onSelect: (opt) => {
721
785
  if (opt.value === "border") {
@@ -734,6 +798,8 @@ const tui = async (api) => {
734
798
  signals.setSectionModel(!cur);
735
799
  if (opt.value === "dist")
736
800
  signals.setSectionDist(!cur);
801
+ if (opt.value === "skills")
802
+ signals.setSectionSkills(!cur);
737
803
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
738
804
  }
739
805
  dialog?.clear();
@@ -751,14 +817,78 @@ const tui = async (api) => {
751
817
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
752
818
  const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
753
819
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
820
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
754
821
  api.ui.toast({
755
822
  title: "Cache Panel Config",
756
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"}`,
823
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
757
824
  duration: 8000,
758
825
  });
759
826
  dialog?.clear();
760
827
  },
761
828
  },
829
+ {
830
+ title: "Cache: Switch Language",
831
+ value: "cache.lang",
832
+ description: "Switch between Chinese and English display",
833
+ slash: { name: "cache-lang" },
834
+ onSelect: (dialog) => {
835
+ const cur = langZH();
836
+ dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Display Language", options: [
837
+ { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
838
+ { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
839
+ ], onSelect: (opt) => {
840
+ const zh = opt.value === "zh";
841
+ api.kv.set(`${KV_PREFIX}.lang`, opt.value);
842
+ setLangZH(zh);
843
+ api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" });
844
+ dialog?.clear();
845
+ } })));
846
+ },
847
+ },
848
+ {
849
+ title: "Cache: Debug Skills Detection",
850
+ value: "cache.debug-skills",
851
+ description: "Dump all tool parts found in the current session for skill detection debugging",
852
+ slash: { name: "cache-debug-skills" },
853
+ onSelect: () => {
854
+ const rt = api.route.current;
855
+ if (rt.name !== "session" || !rt.params) {
856
+ api.ui.toast({ message: "Please run this command inside a session", variant: "warning" });
857
+ return;
858
+ }
859
+ const sid = String(rt.params.sessionID);
860
+ const msgs = api.state.session.messages(sid);
861
+ const byTool = {};
862
+ const skillParts = [];
863
+ for (const msg of msgs) {
864
+ if (msg.role !== "assistant")
865
+ continue;
866
+ let parts = [];
867
+ try {
868
+ parts = api.state.part(msg.id);
869
+ }
870
+ catch { }
871
+ for (const p of parts) {
872
+ if (p.type === "tool") {
873
+ const t = String(p.tool ?? "?");
874
+ byTool[t] = (byTool[t] ?? 0) + 1;
875
+ if (t === "skill") {
876
+ const meta = p.state?.metadata;
877
+ const rootMeta = p.metadata;
878
+ skillParts.push(`state.metadata=${JSON.stringify(meta)} | root.metadata=${JSON.stringify(rootMeta)} | state.title="${p.state?.title}" | state.output[:80]="${String(p.state?.output ?? "").slice(0, 80)}"`);
879
+ }
880
+ }
881
+ }
882
+ }
883
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ");
884
+ const extra = skillParts.length > 0 ? "\n\nSkill parts:\n" + skillParts.join("\n") : "\n\n⚠ No skill tool parts found — AI may be reading SKILL.md instead. Try: 'Use the skill tool to load karpathy-guidelines'";
885
+ api.ui.toast({
886
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
887
+ message: summary + extra,
888
+ duration: 15000,
889
+ });
890
+ },
891
+ },
762
892
  ]);
763
893
  };
764
894
  const mod = {
package/package.json CHANGED
@@ -1,58 +1,58 @@
1
- {
2
- "name": "opencode-visual-cache",
3
- "version": "1.2.10",
4
- "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "import": "./dist/index.js",
11
- "types": "./dist/index.d.ts"
12
- },
13
- "./tui": {
14
- "import": "./src/index.tsx",
15
- "config": {
16
- "enabled": true
17
- }
18
- }
19
- },
20
- "files": [
21
- "dist",
22
- "src",
23
- "install.mjs",
24
- "README.md",
25
- "README_EN.md"
26
- ],
27
- "bin": {
28
- "opencode-visual-cache": "install.mjs"
29
- },
30
- "scripts": {
31
- "build": "tsc",
32
- "typecheck": "tsc --noEmit",
33
- "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"",
34
- "prepublishOnly": "tsc"
35
- },
36
- "keywords": [
37
- "opencode",
38
- "opencode-plugin",
39
- "tui",
40
- "token",
41
- "cache-hit-rate",
42
- "cost-tracking"
43
- ],
44
- "license": "MIT",
45
- "peerDependencies": {
46
- "@opencode-ai/plugin": ">=1.14.0",
47
- "@opencode-ai/sdk": ">=1.14.0",
48
- "@opentui/core": ">=0.2.0",
49
- "@opentui/solid": ">=0.2.0",
50
- "solid-js": ">=1.9.0"
51
- },
52
- "devDependencies": {
53
- "@opencode-ai/plugin": "^1.14.50",
54
- "@opencode-ai/sdk": "^1.14.50",
55
- "tsx": "^4.22.3",
56
- "typescript": "^5.8.0"
57
- }
58
- }
1
+ {
2
+ "name": "opencode-visual-cache",
3
+ "version": "1.2.12",
4
+ "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./tui": {
14
+ "import": "./src/index.tsx",
15
+ "config": {
16
+ "enabled": true
17
+ }
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "install.mjs",
24
+ "README.md",
25
+ "README_EN.md"
26
+ ],
27
+ "bin": {
28
+ "opencode-visual-cache": "install.mjs"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc",
32
+ "typecheck": "tsc --noEmit",
33
+ "version": "node -e \"require('fs').writeFileSync('src/_version.ts','// auto-generated\\nexport const PLUGIN_VERSION='+JSON.stringify(require('./package.json').version)+';\\n')\"",
34
+ "prepublishOnly": "tsc"
35
+ },
36
+ "keywords": [
37
+ "opencode",
38
+ "opencode-plugin",
39
+ "tui",
40
+ "token",
41
+ "cache-hit-rate",
42
+ "cost-tracking"
43
+ ],
44
+ "license": "MIT",
45
+ "peerDependencies": {
46
+ "@opencode-ai/plugin": ">=1.14.0",
47
+ "@opencode-ai/sdk": ">=1.14.0",
48
+ "@opentui/core": ">=0.2.0",
49
+ "@opentui/solid": ">=0.2.0",
50
+ "solid-js": ">=1.9.0"
51
+ },
52
+ "devDependencies": {
53
+ "@opencode-ai/plugin": "^1.14.50",
54
+ "@opencode-ai/sdk": "^1.14.50",
55
+ "tsx": "^4.22.3",
56
+ "typescript": "^5.8.0"
57
+ }
58
+ }
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.2.10";
2
+ export const PLUGIN_VERSION="1.2.12";
package/src/index.tsx CHANGED
@@ -59,6 +59,18 @@ function visualPadEnd(s: string, cols: number): string {
59
59
  return pad > 0 ? s + " ".repeat(pad) : s
60
60
  }
61
61
 
62
+ /** Truncate `s` to fit within `maxCols` visual columns, appending "…" when cut. */
63
+ function truncateVisual(s: string, maxCols: number): string {
64
+ if (visualWidth(s) <= maxCols) return s
65
+ let result = "", w = 0
66
+ for (const c of s) {
67
+ const cw = charColumns(c)
68
+ if (w + cw > maxCols - 1) { result += "\u2026"; break }
69
+ result += c; w += cw
70
+ }
71
+ return result
72
+ }
73
+
62
74
  // ── language override (env: CACHE_TUI_LANG) ──
63
75
  const DEBUG_LANG = typeof process !== "undefined" ? process.env?.CACHE_TUI_LANG : undefined
64
76
 
@@ -71,7 +83,7 @@ const LANG_ZH = DEBUG_LANG
71
83
  catch { return false }
72
84
  })()
73
85
 
74
- const T = LANG_ZH ? {
86
+ const ZH_T = {
75
87
  title: "缓存统计",
76
88
  hit: "命中率",
77
89
  totalHit: "总命中:",
@@ -100,7 +112,10 @@ const T = LANG_ZH ? {
100
112
  distOut: "输出:",
101
113
  secDetail: "明细",
102
114
  secModel: "模型",
103
- } as const : {
115
+ secSkills: "已加载技能",
116
+ } as const
117
+
118
+ const EN_T = {
104
119
  title: "Token Cache",
105
120
  hit: "Hit",
106
121
  totalHit: "Total Hit:",
@@ -129,6 +144,7 @@ const T = LANG_ZH ? {
129
144
  distOut: "Output:",
130
145
  secDetail: "Detail",
131
146
  secModel: "Model",
147
+ secSkills: "Loaded Skills",
132
148
  } as const
133
149
 
134
150
  // ── color helpers ────────────────────────────────────────────────
@@ -317,12 +333,16 @@ interface PanelSignals {
317
333
  setCurrencySymbol: (v: string) => void
318
334
  exchangeRate: () => number
319
335
  setExchangeRate: (v: number) => void
336
+ langZH: () => boolean
337
+ setLangZH: (v: boolean) => void
320
338
  sectionDetail: () => boolean
321
339
  setSectionDetail: (v: boolean) => void
322
340
  sectionModel: () => boolean
323
341
  setSectionModel: (v: boolean) => void
324
342
  sectionDist: () => boolean
325
343
  setSectionDist: (v: boolean) => void
344
+ sectionSkills: () => boolean
345
+ setSectionSkills: (v: boolean) => void
326
346
  borderVisible: () => boolean
327
347
  setBorderVisible: (v: boolean) => void
328
348
  }
@@ -359,18 +379,24 @@ function TokenCachePanel(props: {
359
379
  const [detailOpen, setDetailOpen] = createSignal(true)
360
380
  const [modelOpen, setModelOpen] = createSignal(true)
361
381
  const [distOpen, setDistOpen] = createSignal(false)
382
+ const [skillsOpen, setSkillsOpen] = createSignal(true)
362
383
  let boxEl: any
363
384
 
364
385
  // ── shared signals (de-structured so internal code is unchanged) ──
365
386
  const {
366
387
  currencySymbol, setCurrencySymbol,
367
388
  exchangeRate, setExchangeRate,
389
+ langZH, setLangZH,
368
390
  sectionDetail, setSectionDetail,
369
391
  sectionModel, setSectionModel,
370
392
  sectionDist, setSectionDist,
393
+ sectionSkills, setSectionSkills,
371
394
  borderVisible, setBorderVisible,
372
395
  } = props.signals
373
396
 
397
+ // ── reactive translation (follows langZH signal) ──
398
+ const t = createMemo(() => langZH() ? ZH_T : EN_T)
399
+
374
400
  // ── scan session messages reactively ──
375
401
  // SolidJS createMemo re-evaluates whenever the underlying
376
402
  // api.state.session state changes — no event listener needed.
@@ -393,6 +419,8 @@ function TokenCachePanel(props: {
393
419
  providerName: "", sessionHitRate: 0,
394
420
  dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
395
421
  hasDistData: false,
422
+ skills: [] as { name: string; tokens: number }[],
423
+ hasSkills: false,
396
424
  })
397
425
  const [refreshTick, setRefreshTick] = createSignal(0)
398
426
 
@@ -432,6 +460,7 @@ function TokenCachePanel(props: {
432
460
  const distData = untrack(() => {
433
461
  let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
434
462
  let hasDistData = false
463
+ const loadedSkills = new Map<string, { name: string; tokens: number }>()
435
464
  try {
436
465
  const session = props.api.state.session.get(sid), cfg = props.api.state.config as Record<string, unknown>
437
466
  const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
@@ -459,6 +488,24 @@ function TokenCachePanel(props: {
459
488
  if (rawInput) dist.toolCall += estimateTokens(rawInput)
460
489
  if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
461
490
  else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
491
+ if (tp.tool === "skill" && tp.state.status === "completed") {
492
+ // TUI SDK strips tool metadata — extract skill name from well-known output format.
493
+ // Cross-validated against api.client.app.skills() when available.
494
+ let name: string | undefined = tp.state.metadata?.name
495
+ if (typeof name !== "string") {
496
+ const m = typeof tp.state.output === "string"
497
+ ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
498
+ : null
499
+ if (m) name = m[1].trim()
500
+ }
501
+ if (typeof name === "string") {
502
+ const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0
503
+ const existing = loadedSkills.get(name)
504
+ if (!existing || existing.tokens < tokens) {
505
+ loadedSkills.set(name, { name, tokens })
506
+ }
507
+ }
508
+ }
462
509
  } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
463
510
  else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
464
511
  }
@@ -476,7 +523,8 @@ function TokenCachePanel(props: {
476
523
  hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
477
524
  } catch {}
478
525
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
479
- return { finalDist, finalHasDist }
526
+ const skills = [...loadedSkills.values()]
527
+ return { finalDist, finalHasDist, skills }
480
528
  })
481
529
 
482
530
  setDataSignal({
@@ -485,6 +533,7 @@ function TokenCachePanel(props: {
485
533
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
486
534
  trend, hasTrendData, providerName, sessionHitRate,
487
535
  dist: distData.finalDist, hasDistData: distData.finalHasDist,
536
+ skills: distData.skills, hasSkills: distData.skills.length > 0,
488
537
  })
489
538
  })
490
539
 
@@ -524,6 +573,7 @@ function TokenCachePanel(props: {
524
573
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
525
574
  setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
526
575
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
576
+ setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
527
577
  } catch {}
528
578
 
529
579
  // Restore user config (currency, rate, section visibility).
@@ -538,8 +588,14 @@ function TokenCachePanel(props: {
538
588
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
539
589
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
540
590
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
591
+ setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
541
592
  const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
542
593
  setBorderVisible(bv !== false)
594
+ // Restore language preference
595
+ const savedLang = props.api.kv.get<string>(`${KV_PREFIX}.lang`)
596
+ if (savedLang === "zh" || savedLang === "en") {
597
+ setLangZH(savedLang === "zh")
598
+ }
543
599
  // Restore distribution snapshot so the token distribution block
544
600
  // doesn't blank out while api.state.part() re-hydrates.
545
601
  const cachedDist = props.api.kv.get<TokenDist>(`${KV_PREFIX}.dist_snapshot`)
@@ -623,7 +679,7 @@ function TokenCachePanel(props: {
623
679
 
624
680
  const barW = createMemo(() => {
625
681
  const trendSpace = data().hasTrendData ? LABEL_GAP + visualWidth(trendLabel(data().trend)) : 0
626
- const overhead = visualWidth(T.hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
682
+ const overhead = visualWidth(t().hit) + LABEL_GAP + BAR_BRACKETS + BAR_GAP + PCT_FIXED_WIDTH + trendSpace + gutter()
627
683
  return Math.max(3, panelWidth() - overhead)
628
684
  })
629
685
  const bar = createMemo(() => progressBar(data().hitRate, barW()))
@@ -669,7 +725,7 @@ function TokenCachePanel(props: {
669
725
  <text onMouseUp={() => setOpen((o) => { const n = !o; persistFold("open", n); return n })}>
670
726
  <span style={{ fg: pal().muted }}>{open() ? "\u25bc " : "\u25b6 "}</span>
671
727
  <span style={{ fg: pal().primary }}>
672
- <b>{T.title}</b>
728
+ <b>{t().title}</b>
673
729
  <Show when={open()}>
674
730
  <span style={{ fg: pal().muted }}> (v{PLUGIN_VERSION})</span>
675
731
  </Show>
@@ -677,18 +733,18 @@ function TokenCachePanel(props: {
677
733
  <Show when={!open() && data().hasData}>
678
734
  <Show when={data().hasTrendData}>
679
735
  <span>
680
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend))))}
736
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded + " " + trendLabel(data().trend))))}
681
737
  </span>
682
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
738
+ <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
683
739
  <span style={{ fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }}>
684
740
  {" "}{trendLabel(data().trend)}
685
741
  </span>
686
742
  </Show>
687
743
  <Show when={!data().hasTrendData}>
688
744
  <span>
689
- {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded)))}
745
+ {" ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(t().title) - visualWidth(pct() + " " + t().hitFolded)))}
690
746
  </span>
691
- <span style={{ fg: hitColor() }}>{pct()} {T.hitFolded}</span>
747
+ <span style={{ fg: hitColor() }}>{pct()} {t().hitFolded}</span>
692
748
  </Show>
693
749
  </Show>
694
750
  </text>
@@ -699,7 +755,7 @@ function TokenCachePanel(props: {
699
755
  <text fg={pal().muted}>{sep()}</text>
700
756
  <text>
701
757
  <span style={{ fg: pal().muted }}>{"> "}</span>
702
- <span style={{ fg: pal().muted }}>{T.noData}</span>
758
+ <span style={{ fg: pal().muted }}>{t().noData}</span>
703
759
  </text>
704
760
  </>
705
761
  }>
@@ -707,7 +763,7 @@ function TokenCachePanel(props: {
707
763
 
708
764
  {/* hit rate + bar — inline to avoid box spacing */}
709
765
  <text>
710
- <span style={{ fg: pal().text }}>{T.hit} </span>
766
+ <span style={{ fg: pal().text }}>{t().hit} </span>
711
767
  <span style={{ fg: hitColor() }}>[{bar()}] </span>
712
768
  <span style={{ fg: pal().text }}>{pct()}</span>
713
769
  <Show when={data().hasTrendData}>
@@ -719,38 +775,38 @@ function TokenCachePanel(props: {
719
775
 
720
776
  {/* session cumulative hit rate */}
721
777
  <text fg={pal().muted}>
722
- {justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
778
+ {justify(t().totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%")}
723
779
  </text>
724
780
 
725
781
  {/* ── detail section (collapsible, default open) ── */}
726
782
  <Show when={sectionDetail()}>
727
783
  <text onMouseUp={() => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n })}>
728
784
  <span style={{ fg: pal().muted }}>{detailOpen() ? "\u25bc " : "\u25b6 "}</span>
729
- <span style={{ fg: pal().primary }}><b>{T.secDetail}</b></span>
730
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail))}</span>
785
+ <span style={{ fg: pal().primary }}><b>{t().secDetail}</b></span>
786
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + t().secDetail))}</span>
731
787
  </text>
732
788
 
733
789
  <Show when={detailOpen()}>
734
790
  <Show when={data().read > 0}>
735
791
  <text fg={pal().muted}>
736
- {justify(T.read, fmt(data().read), T.tok)}
792
+ {justify(t().read, fmt(data().read), t().tok)}
737
793
  </text>
738
794
  </Show>
739
795
  <Show when={data().write > 0}>
740
796
  <text fg={pal().muted}>
741
- {justify(T.write, fmt(data().write), T.tok)}
797
+ {justify(t().write, fmt(data().write), t().tok)}
742
798
  </text>
743
799
  </Show>
744
800
  <text fg={pal().muted}>
745
- {justify(T.miss, fmt(data().freshInput), T.tok)}
801
+ {justify(t().miss, fmt(data().freshInput), t().tok)}
746
802
  </text>
747
803
  <text fg={pal().muted}>
748
- {justify(T.out, fmt(data().output), T.tok)}
804
+ {justify(t().out, fmt(data().output), t().tok)}
749
805
  </text>
750
806
  <Show when={data().saved > 0}>
751
807
  <text>
752
- <span style={{ fg: pal().muted }}>{T.saved}</span>
753
- <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
808
+ <span style={{ fg: pal().muted }}>{t().saved}</span>
809
+ <span>{" ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(t().saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate()))))}</span>
754
810
  <span style={{ fg: pal().success }}>~{fmtCost(data().saved, currencySymbol(), exchangeRate())}</span>
755
811
  </text>
756
812
  </Show>
@@ -761,34 +817,34 @@ function TokenCachePanel(props: {
761
817
  <Show when={sectionModel()}>
762
818
  {<text onMouseUp={() => setModelOpen((o) => { const n = !o; persistFold("model", n); return n })}>
763
819
  <span style={{ fg: pal().muted }}>{modelOpen() ? "\u25bc " : "\u25b6 "}</span>
764
- <span style={{ fg: pal().primary }}><b>{T.secModel}</b></span>
765
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel))}</span>
820
+ <span style={{ fg: pal().primary }}><b>{t().secModel}</b></span>
821
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + t().secModel))}</span>
766
822
  </text>}
767
823
 
768
824
  <Show when={modelOpen()}>
769
825
  <text fg={pal().text}>
770
- {justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
826
+ {justify(t().cost, fmtCost(data().cost, currencySymbol(), exchangeRate()))}
771
827
  </text>
772
828
  <Show when={data().providerName}>
773
829
  <text fg={pal().muted}>
774
- {justify(T.provider, data().providerName)}
830
+ {justify(t().provider, data().providerName)}
775
831
  </text>
776
832
  </Show>
777
833
  <text fg={pal().muted}>
778
- {justify(T.model, data().model)}
834
+ {justify(t().model, data().model)}
779
835
  </text>
780
836
  <Show when={data().hasPricing}>
781
837
  <text fg={pal().muted}>
782
- {justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate)}
838
+ {justify(t().rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + t().inputRate)}
783
839
  </text>
784
840
  <Show when={data().cacheReadRate > 0}>
785
841
  <text fg={pal().muted}>
786
- {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate)}
842
+ {justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + t().cacheRate)}
787
843
  </text>
788
844
  </Show>
789
845
  <Show when={data().cacheWriteRate > 0}>
790
846
  <text fg={pal().muted}>
791
- {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate)}
847
+ {justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + t().writeRate)}
792
848
  </text>
793
849
  </Show>
794
850
  </Show>
@@ -800,44 +856,68 @@ function TokenCachePanel(props: {
800
856
  <Show when={data().hasDistData}>
801
857
  {<text onMouseUp={() => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n })}>
802
858
  <span style={{ fg: pal().muted }}>{distOpen() ? "\u25bc " : "\u25b6 "}</span>
803
- <span style={{ fg: pal().primary }}><b>{T.distTitle}</b></span>
804
- <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle))}</span>
859
+ <span style={{ fg: pal().primary }}><b>{t().distTitle}</b></span>
860
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + t().distTitle))}</span>
805
861
  </text>}
806
862
  <Show when={distOpen()}>
807
863
  <Show when={data().dist.system > 0}>
808
864
  <text fg={pal().muted}>
809
- {justify(T.distSys, fmt(data().dist.system), T.tok)}
865
+ {justify(t().distSys, fmt(data().dist.system), t().tok)}
810
866
  </text>
811
867
  </Show>
812
868
  <Show when={data().dist.user > 0}>
813
869
  <text fg={pal().muted}>
814
- {justify(T.distUser, fmt(data().dist.user), T.tok)}
870
+ {justify(t().distUser, fmt(data().dist.user), t().tok)}
815
871
  </text>
816
872
  </Show>
817
873
  <Show when={data().dist.agent > 0}>
818
874
  <text fg={pal().muted}>
819
- {justify(T.distAgent, fmt(data().dist.agent), T.tok)}
875
+ {justify(t().distAgent, fmt(data().dist.agent), t().tok)}
820
876
  </text>
821
877
  </Show>
822
878
  <Show when={data().dist.toolCall > 0}>
823
879
  <text fg={pal().muted}>
824
- {justify(T.distTool, fmt(data().dist.toolCall), T.tok)}
880
+ {justify(t().distTool, fmt(data().dist.toolCall), t().tok)}
825
881
  </text>
826
882
  </Show>
827
883
  <Show when={data().dist.toolResult > 0}>
828
884
  <text fg={pal().muted}>
829
- {justify(T.distRes, fmt(data().dist.toolResult), T.tok)}
885
+ {justify(t().distRes, fmt(data().dist.toolResult), t().tok)}
830
886
  </text>
831
887
  </Show>
832
888
  <text fg={pal().text}>
833
- {justify(T.distTotal, fmt(data().dist.apiInput), T.tok)}
889
+ {justify(t().distTotal, fmt(data().dist.apiInput), t().tok)}
834
890
  </text>
835
891
  </Show>
836
892
  </Show>
837
893
  </Show>
894
+
895
+ {/* ── loaded skills (collapsible, default open) ── */}
896
+ <Show when={sectionSkills()}>
897
+ <Show when={data().hasSkills}>
898
+ {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
899
+ <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
900
+ <span style={{ fg: pal().primary }}><b>{t().secSkills}</b></span>
901
+ <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
902
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))}</span>
903
+ </text>}
904
+ <Show when={skillsOpen()}>
905
+ {data().skills.map((sk: { name: string; tokens: number }) => {
906
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok)
907
+ const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
908
+ const label = truncateVisual(sk.name, maxLabel)
909
+ return (
910
+ <text fg={pal().muted}>
911
+ {justify(label, fmt(sk.tokens), t().tok)}
912
+ </text>
913
+ )
914
+ })}
915
+ </Show>
916
+ </Show>
917
+ </Show>
838
918
  </Show>
839
919
  </Show>
840
- </box>
920
+ </box>
841
921
  )
842
922
  }
843
923
 
@@ -870,14 +950,18 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
870
950
  const [sectionDetail, setSectionDetail] = createSignal(true)
871
951
  const [sectionModel, setSectionModel] = createSignal(true)
872
952
  const [sectionDist, setSectionDist] = createSignal(true)
953
+ const [sectionSkills, setSectionSkills] = createSignal(true)
873
954
  const [borderVisible, setBorderVisible] = createSignal(true)
955
+ const [langZH, setLangZH] = createSignal(LANG_ZH)
874
956
 
875
957
  const signals: PanelSignals = {
876
958
  currencySymbol, setCurrencySymbol,
877
959
  exchangeRate, setExchangeRate,
960
+ langZH, setLangZH,
878
961
  sectionDetail, setSectionDetail,
879
962
  sectionModel, setSectionModel,
880
963
  sectionDist, setSectionDist,
964
+ sectionSkills, setSectionSkills,
881
965
  borderVisible, setBorderVisible,
882
966
  }
883
967
 
@@ -947,6 +1031,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
947
1031
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
948
1032
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
949
1033
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1034
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
950
1035
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
951
1036
  dialog?.replace(() => (
952
1037
  <api.ui.DialogSelect
@@ -955,6 +1040,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
955
1040
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
956
1041
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
957
1042
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1043
+ { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
958
1044
  { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
959
1045
  ]}
960
1046
  onSelect={(opt) => {
@@ -970,6 +1056,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
970
1056
  if (opt.value === "detail") signals.setSectionDetail(!cur)
971
1057
  if (opt.value === "model") signals.setSectionModel(!cur)
972
1058
  if (opt.value === "dist") signals.setSectionDist(!cur)
1059
+ if (opt.value === "skills") signals.setSectionSkills(!cur)
973
1060
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
974
1061
  }
975
1062
  dialog?.clear()
@@ -989,14 +1076,80 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
989
1076
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
990
1077
  const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
991
1078
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1079
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
992
1080
  api.ui.toast({
993
1081
  title: "Cache Panel Config",
994
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"}`,
1082
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
995
1083
  duration: 8000,
996
1084
  })
997
1085
  dialog?.clear()
998
1086
  },
999
1087
  },
1088
+ {
1089
+ title: "Cache: Switch Language",
1090
+ value: "cache.lang",
1091
+ description: "Switch between Chinese and English display",
1092
+ slash: { name: "cache-lang" },
1093
+ onSelect: (dialog) => {
1094
+ const cur = langZH()
1095
+ dialog?.replace(() => (
1096
+ <api.ui.DialogSelect
1097
+ title="Display Language"
1098
+ options={[
1099
+ { title: `中文 ${cur ? "\u2713" : ""}`, value: "zh" },
1100
+ { title: `English ${cur ? "" : "\u2713"}`, value: "en" },
1101
+ ]}
1102
+ onSelect={(opt) => {
1103
+ const zh = opt.value === "zh"
1104
+ api.kv.set(`${KV_PREFIX}.lang`, opt.value)
1105
+ setLangZH(zh)
1106
+ api.ui.toast({ message: zh ? "语言已切换为中文" : "Switched to English" })
1107
+ dialog?.clear()
1108
+ }}
1109
+ />
1110
+ ))
1111
+ },
1112
+ },
1113
+ {
1114
+ title: "Cache: Debug Skills Detection",
1115
+ value: "cache.debug-skills",
1116
+ description: "Dump all tool parts found in the current session for skill detection debugging",
1117
+ slash: { name: "cache-debug-skills" },
1118
+ onSelect: () => {
1119
+ const rt = api.route.current
1120
+ if (rt.name !== "session" || !rt.params) {
1121
+ api.ui.toast({ message: "Please run this command inside a session", variant: "warning" })
1122
+ return
1123
+ }
1124
+ const sid = String(rt.params.sessionID)
1125
+ const msgs = api.state.session.messages(sid)
1126
+ const byTool: Record<string, number> = {}
1127
+ const skillParts: string[] = []
1128
+ for (const msg of msgs) {
1129
+ if (msg.role !== "assistant") continue
1130
+ let parts: readonly any[] = []
1131
+ try { parts = api.state.part(msg.id) } catch {}
1132
+ for (const p of parts) {
1133
+ if (p.type === "tool") {
1134
+ const t = String(p.tool ?? "?")
1135
+ byTool[t] = (byTool[t] ?? 0) + 1
1136
+ if (t === "skill") {
1137
+ const meta = p.state?.metadata
1138
+ const rootMeta = p.metadata
1139
+ skillParts.push(`state.metadata=${JSON.stringify(meta)} | root.metadata=${JSON.stringify(rootMeta)} | state.title="${p.state?.title}" | state.output[:80]="${String(p.state?.output ?? "").slice(0, 80)}"`)
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
1145
+ const extra = skillParts.length > 0 ? "\n\nSkill parts:\n" + skillParts.join("\n") : "\n\n⚠ No skill tool parts found — AI may be reading SKILL.md instead. Try: 'Use the skill tool to load karpathy-guidelines'"
1146
+ api.ui.toast({
1147
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
1148
+ message: summary + extra,
1149
+ duration: 15000,
1150
+ })
1151
+ },
1152
+ },
1000
1153
  ])
1001
1154
  }
1002
1155