opencode-visual-cache 1.2.11 → 1.2.13

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
@@ -47,6 +47,7 @@
47
47
  - **语言适配**:自动检测系统语言,支持 `/cache-lang` 运行时切换中/英文,偏好持久化
48
48
  - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算
49
49
  - **斜杠命令**:`/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板
50
+ - **已加载技能**:检测 session 中 LLM 调用 `skill` tool 的记录,展示已加载技能名及估算 Token 占用
50
51
 
51
52
  ---
52
53
 
@@ -97,7 +98,7 @@ 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` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 |
102
103
  | `/cache-lang` | 切换显示语言 | 从列表选择中文或 English,界面即时切换,无需重启 |
103
104
 
@@ -127,11 +128,12 @@ npm install -g opencode-visual-cache@latest
127
128
 
128
129
  ### 4.3 区块可见性
129
130
 
130
- 面板中的三个子区块可以独立关闭,方便在侧边栏空间紧张时隐藏不需要的信息:
131
+ 面板中的子区块可以独立关闭,方便在侧边栏空间紧张时隐藏不需要的信息:
131
132
 
132
133
  - **Token 明细**:缓存读 / 缓存写 / 未命中 / 输出
133
134
  - **模型与定价**:费用 / 提供商 / 模型名 / 单价
134
135
  - **估算 Token 分布**:按角色拆分的 Token 估算
136
+ - **已加载技能**:session 中 LLM 实际调用过的 Skill 名及估算 Token 占用
135
137
 
136
138
  通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。
137
139
 
package/README_EN.md CHANGED
@@ -47,6 +47,7 @@ If you find this plugin useful, a ⭐ would mean a lot — thank you!<br>
47
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
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,7 +98,7 @@ 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 |
102
103
  | `/cache-lang` | Switch display language | Pick Chinese or English from the dialog — takes effect immediately, no restart needed |
103
104
 
@@ -132,6 +133,7 @@ Three sub-sections can be toggled independently to save sidebar space:
132
133
  - **Token Detail**: cache read / write / miss / output
133
134
  - **Model & Pricing**: cost / provider / model name / per-million rates
134
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
135
137
 
136
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.
137
139
 
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.11";
1
+ export declare const PLUGIN_VERSION = "1.2.13";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.11";
2
+ export const PLUGIN_VERSION = "1.2.13";
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 ──────────────────────────────────────────────────────
@@ -77,6 +93,7 @@ const ZH_T = {
77
93
  distOut: "输出:",
78
94
  secDetail: "明细",
79
95
  secModel: "模型",
96
+ secSkills: "已加载技能",
80
97
  };
81
98
  const EN_T = {
82
99
  title: "Token Cache",
@@ -107,6 +124,7 @@ const EN_T = {
107
124
  distOut: "Output:",
108
125
  secDetail: "Detail",
109
126
  secModel: "Model",
127
+ secSkills: "Loaded Skills",
110
128
  };
111
129
  // ── color helpers ────────────────────────────────────────────────
112
130
  /** Extract { r, g, b } (0–255) from a hex string or RGBA-like object. */
@@ -294,9 +312,10 @@ function TokenCachePanel(props) {
294
312
  const [detailOpen, setDetailOpen] = createSignal(true);
295
313
  const [modelOpen, setModelOpen] = createSignal(true);
296
314
  const [distOpen, setDistOpen] = createSignal(false);
315
+ const [skillsOpen, setSkillsOpen] = createSignal(true);
297
316
  let boxEl;
298
317
  // ── shared signals (de-structured so internal code is unchanged) ──
299
- const { currencySymbol, setCurrencySymbol, exchangeRate, setExchangeRate, langZH, setLangZH, 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;
300
319
  // ── reactive translation (follows langZH signal) ──
301
320
  const t = createMemo(() => langZH() ? ZH_T : EN_T);
302
321
  // ── scan session messages reactively ──
@@ -319,6 +338,8 @@ function TokenCachePanel(props) {
319
338
  providerName: "", sessionHitRate: 0,
320
339
  dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
321
340
  hasDistData: false,
341
+ skills: [],
342
+ hasSkills: false,
322
343
  });
323
344
  const [refreshTick, setRefreshTick] = createSignal(0);
324
345
  createEffect(() => {
@@ -327,7 +348,19 @@ function TokenCachePanel(props) {
327
348
  void partVersion();
328
349
  // 自然追踪 messages 和 provider(SDK 数据就绪时自动重新执行)
329
350
  const msgs = props.api.state.session.messages(sid);
330
- let input = 0, read = 0, write = 0, output = 0, cost = 0, pid = "", mid = "";
351
+ const session = props.api.state.session.get(sid);
352
+ // 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
353
+ // 若字段不存在(旧版本 SDK),降级到消息遍历累加
354
+ let input = session?.tokens?.input ?? 0;
355
+ let read = session?.tokens?.cache?.read ?? 0;
356
+ let write = session?.tokens?.cache?.write ?? 0;
357
+ let output = session?.tokens?.output ?? 0;
358
+ let cost = session?.cost ?? 0;
359
+ let pid = session?.model?.providerID ?? "";
360
+ let mid = session?.model?.id ?? "";
361
+ const fallbackTokens = session?.tokens == null;
362
+ const fallbackCost = session?.cost == null;
363
+ const fallbackModel = !pid || !mid;
331
364
  let prevMsgHitRate = -1, lastMsgHitRate = -1;
332
365
  for (const msg of msgs) {
333
366
  if (msg.role !== "assistant")
@@ -340,12 +373,16 @@ function TokenCachePanel(props) {
340
373
  prevMsgHitRate = lastMsgHitRate;
341
374
  lastMsgHitRate = (mrt / mit) * 100;
342
375
  }
343
- input += num(t.input);
344
- read += num(t.cache?.read);
345
- write += num(t.cache?.write);
346
- output += num(t.output);
347
- cost += num(msg.cost);
348
- if (msg.providerID && msg.modelID) {
376
+ if (fallbackTokens) {
377
+ input += num(t.input);
378
+ read += num(t.cache?.read);
379
+ write += num(t.cache?.write);
380
+ output += num(t.output);
381
+ }
382
+ if (fallbackCost) {
383
+ cost += num(msg.cost);
384
+ }
385
+ if (fallbackModel && msg.providerID && msg.modelID) {
349
386
  pid = msg.providerID;
350
387
  mid = msg.modelID;
351
388
  }
@@ -374,8 +411,9 @@ function TokenCachePanel(props) {
374
411
  const distData = untrack(() => {
375
412
  let dist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 };
376
413
  let hasDistData = false;
414
+ const loadedSkills = new Map();
377
415
  try {
378
- const session = props.api.state.session.get(sid), cfg = props.api.state.config;
416
+ const cfg = props.api.state.config;
379
417
  const agentName = String(session?.agent ?? cfg?.default_agent ?? "build");
380
418
  const agents = cfg?.agent;
381
419
  const agentCfg = agents?.[agentName];
@@ -431,6 +469,25 @@ function TokenCachePanel(props) {
431
469
  if (e.error)
432
470
  dist.toolResult += estimateTokens(e.error);
433
471
  }
472
+ if (tp.tool === "skill" && tp.state.status === "completed") {
473
+ // TUI SDK strips tool metadata — extract skill name from well-known output format.
474
+ // Cross-validated against api.client.app.skills() when available.
475
+ let name = tp.state.metadata?.name;
476
+ if (typeof name !== "string") {
477
+ const m = typeof tp.state.output === "string"
478
+ ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
479
+ : null;
480
+ if (m)
481
+ name = m[1].trim();
482
+ }
483
+ if (typeof name === "string") {
484
+ const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0;
485
+ const existing = loadedSkills.get(name);
486
+ if (!existing || existing.tokens < tokens) {
487
+ loadedSkills.set(name, { name, tokens });
488
+ }
489
+ }
490
+ }
434
491
  }
435
492
  else if (p.type === "reasoning")
436
493
  dist.agent += estimateTokens(p.text);
@@ -458,7 +515,8 @@ function TokenCachePanel(props) {
458
515
  }
459
516
  catch { }
460
517
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist();
461
- return { finalDist, finalHasDist };
518
+ const skills = [...loadedSkills.values()];
519
+ return { finalDist, finalHasDist, skills };
462
520
  });
463
521
  setDataSignal({
464
522
  hitRate, read, write, freshInput: input, output, cost, saved, model,
@@ -466,6 +524,7 @@ function TokenCachePanel(props) {
466
524
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
467
525
  trend, hasTrendData, providerName, sessionHitRate,
468
526
  dist: distData.finalDist, hasDistData: distData.finalHasDist,
527
+ skills: distData.skills, hasSkills: distData.skills.length > 0,
469
528
  });
470
529
  });
471
530
  const data = createMemo(() => {
@@ -505,6 +564,7 @@ function TokenCachePanel(props) {
505
564
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)));
506
565
  setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)));
507
566
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)));
567
+ setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)));
508
568
  }
509
569
  catch { }
510
570
  // Restore user config (currency, rate, section visibility).
@@ -521,6 +581,7 @@ function TokenCachePanel(props) {
521
581
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
522
582
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
523
583
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
584
+ setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)));
524
585
  const bv = props.api.kv.get(`${KV_PREFIX}.border`, true);
525
586
  setBorderVisible(bv !== false);
526
587
  // Restore language preference
@@ -637,7 +698,12 @@ function TokenCachePanel(props) {
637
698
  // boxEl.width may be undefined before the first measurement — guard with 0
638
699
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
639
700
  setPanelWidth((prev) => (prev === w ? prev : w));
640
- }, 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) })] })] }) })] }) })] }));
701
+ }, 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) => {
702
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok);
703
+ const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1);
704
+ const label = truncateVisual(sk.name, maxLabel);
705
+ return (_jsx("text", { fg: pal().muted, children: justify(label, fmt(sk.tokens), t().tok) }));
706
+ }) })] }) })] }) })] }));
641
707
  }
642
708
  // ---------------------------------------------------------------------------
643
709
  // Plugin entry
@@ -659,6 +725,7 @@ const tui = async (api) => {
659
725
  const [sectionDetail, setSectionDetail] = createSignal(true);
660
726
  const [sectionModel, setSectionModel] = createSignal(true);
661
727
  const [sectionDist, setSectionDist] = createSignal(true);
728
+ const [sectionSkills, setSectionSkills] = createSignal(true);
662
729
  const [borderVisible, setBorderVisible] = createSignal(true);
663
730
  const [langZH, setLangZH] = createSignal(LANG_ZH);
664
731
  const signals = {
@@ -668,6 +735,7 @@ const tui = async (api) => {
668
735
  sectionDetail, setSectionDetail,
669
736
  sectionModel, setSectionModel,
670
737
  sectionDist, setSectionDist,
738
+ sectionSkills, setSectionSkills,
671
739
  borderVisible, setBorderVisible,
672
740
  };
673
741
  api.slots.register(createSidebarSlot(api, signals));
@@ -721,11 +789,13 @@ const tui = async (api) => {
721
789
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
722
790
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
723
791
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
792
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
724
793
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true));
725
794
  dialog?.replace(() => (_jsx(api.ui.DialogSelect, { title: "Toggle Section", options: [
726
795
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
727
796
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
728
797
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
798
+ { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
729
799
  { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
730
800
  ], onSelect: (opt) => {
731
801
  if (opt.value === "border") {
@@ -744,6 +814,8 @@ const tui = async (api) => {
744
814
  signals.setSectionModel(!cur);
745
815
  if (opt.value === "dist")
746
816
  signals.setSectionDist(!cur);
817
+ if (opt.value === "skills")
818
+ signals.setSectionSkills(!cur);
747
819
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` });
748
820
  }
749
821
  dialog?.clear();
@@ -761,9 +833,10 @@ const tui = async (api) => {
761
833
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true));
762
834
  const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true));
763
835
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true));
836
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true));
764
837
  api.ui.toast({
765
838
  title: "Cache Panel Config",
766
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"}`,
839
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
767
840
  duration: 8000,
768
841
  });
769
842
  dialog?.clear();
@@ -788,6 +861,50 @@ const tui = async (api) => {
788
861
  } })));
789
862
  },
790
863
  },
864
+ {
865
+ title: "Cache: Debug Skills Detection",
866
+ value: "cache.debug-skills",
867
+ description: "Dump all tool parts found in the current session for skill detection debugging",
868
+ slash: { name: "cache-debug-skills" },
869
+ onSelect: () => {
870
+ const rt = api.route.current;
871
+ if (rt.name !== "session" || !rt.params) {
872
+ api.ui.toast({ message: "Please run this command inside a session", variant: "warning" });
873
+ return;
874
+ }
875
+ const sid = String(rt.params.sessionID);
876
+ const msgs = api.state.session.messages(sid);
877
+ const byTool = {};
878
+ const skillParts = [];
879
+ for (const msg of msgs) {
880
+ if (msg.role !== "assistant")
881
+ continue;
882
+ let parts = [];
883
+ try {
884
+ parts = api.state.part(msg.id);
885
+ }
886
+ catch { }
887
+ for (const p of parts) {
888
+ if (p.type === "tool") {
889
+ const t = String(p.tool ?? "?");
890
+ byTool[t] = (byTool[t] ?? 0) + 1;
891
+ if (t === "skill") {
892
+ const meta = p.state?.metadata;
893
+ const rootMeta = p.metadata;
894
+ 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)}"`);
895
+ }
896
+ }
897
+ }
898
+ }
899
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ");
900
+ 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'";
901
+ api.ui.toast({
902
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
903
+ message: summary + extra,
904
+ duration: 15000,
905
+ });
906
+ },
907
+ },
791
908
  ]);
792
909
  };
793
910
  const mod = {
package/package.json CHANGED
@@ -1,58 +1,58 @@
1
- {
2
- "name": "opencode-visual-cache",
3
- "version": "1.2.11",
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.13",
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.11";
2
+ export const PLUGIN_VERSION="1.2.13";
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
 
@@ -100,6 +112,7 @@ const ZH_T = {
100
112
  distOut: "输出:",
101
113
  secDetail: "明细",
102
114
  secModel: "模型",
115
+ secSkills: "已加载技能",
103
116
  } as const
104
117
 
105
118
  const EN_T = {
@@ -131,6 +144,7 @@ const EN_T = {
131
144
  distOut: "Output:",
132
145
  secDetail: "Detail",
133
146
  secModel: "Model",
147
+ secSkills: "Loaded Skills",
134
148
  } as const
135
149
 
136
150
  // ── color helpers ────────────────────────────────────────────────
@@ -327,6 +341,8 @@ interface PanelSignals {
327
341
  setSectionModel: (v: boolean) => void
328
342
  sectionDist: () => boolean
329
343
  setSectionDist: (v: boolean) => void
344
+ sectionSkills: () => boolean
345
+ setSectionSkills: (v: boolean) => void
330
346
  borderVisible: () => boolean
331
347
  setBorderVisible: (v: boolean) => void
332
348
  }
@@ -363,6 +379,7 @@ function TokenCachePanel(props: {
363
379
  const [detailOpen, setDetailOpen] = createSignal(true)
364
380
  const [modelOpen, setModelOpen] = createSignal(true)
365
381
  const [distOpen, setDistOpen] = createSignal(false)
382
+ const [skillsOpen, setSkillsOpen] = createSignal(true)
366
383
  let boxEl: any
367
384
 
368
385
  // ── shared signals (de-structured so internal code is unchanged) ──
@@ -373,6 +390,7 @@ function TokenCachePanel(props: {
373
390
  sectionDetail, setSectionDetail,
374
391
  sectionModel, setSectionModel,
375
392
  sectionDist, setSectionDist,
393
+ sectionSkills, setSectionSkills,
376
394
  borderVisible, setBorderVisible,
377
395
  } = props.signals
378
396
 
@@ -401,6 +419,8 @@ function TokenCachePanel(props: {
401
419
  providerName: "", sessionHitRate: 0,
402
420
  dist: { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 },
403
421
  hasDistData: false,
422
+ skills: [] as { name: string; tokens: number }[],
423
+ hasSkills: false,
404
424
  })
405
425
  const [refreshTick, setRefreshTick] = createSignal(0)
406
426
 
@@ -411,16 +431,37 @@ function TokenCachePanel(props: {
411
431
 
412
432
  // 自然追踪 messages 和 provider(SDK 数据就绪时自动重新执行)
413
433
  const msgs = props.api.state.session.messages(sid) as Message[]
414
- let input = 0, read = 0, write = 0, output = 0, cost = 0, pid = "", mid = ""
434
+ const session = props.api.state.session.get(sid)
435
+
436
+ // 累计值优先使用 Session 聚合字段(数据库级,不受 sync 层 limit:100 截断)
437
+ // 若字段不存在(旧版本 SDK),降级到消息遍历累加
438
+ let input = session?.tokens?.input ?? 0
439
+ let read = session?.tokens?.cache?.read ?? 0
440
+ let write = session?.tokens?.cache?.write ?? 0
441
+ let output = session?.tokens?.output ?? 0
442
+ let cost = session?.cost ?? 0
443
+ let pid = session?.model?.providerID ?? ""
444
+ let mid = session?.model?.id ?? ""
445
+
446
+ const fallbackTokens = session?.tokens == null
447
+ const fallbackCost = session?.cost == null
448
+ const fallbackModel = !pid || !mid
449
+
415
450
  let prevMsgHitRate = -1, lastMsgHitRate = -1
416
451
  for (const msg of msgs) {
417
452
  if (msg.role !== "assistant") continue
418
453
  const t = (msg as AssistantMessage).tokens; if (!t) continue
419
454
  const mit = num(t.input) + num(t.cache?.read), mrt = num(t.cache?.read)
420
455
  if (mit > 0) { prevMsgHitRate = lastMsgHitRate; lastMsgHitRate = (mrt / mit) * 100 }
421
- input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output)
422
- cost += num((msg as AssistantMessage).cost)
423
- if ((msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) { pid = (msg as AssistantMessage).providerID; mid = (msg as AssistantMessage).modelID }
456
+ if (fallbackTokens) {
457
+ input += num(t.input); read += num(t.cache?.read); write += num(t.cache?.write); output += num(t.output)
458
+ }
459
+ if (fallbackCost) {
460
+ cost += num((msg as AssistantMessage).cost)
461
+ }
462
+ if (fallbackModel && (msg as AssistantMessage).providerID && (msg as AssistantMessage).modelID) {
463
+ pid = (msg as AssistantMessage).providerID; mid = (msg as AssistantMessage).modelID
464
+ }
424
465
  }
425
466
  let saved = 0, inputRate = 0, cacheReadRate = 0, cacheWriteRate = 0
426
467
  if (read > 0 && pid && mid) for (const provider of props.api.state.provider) {
@@ -440,8 +481,9 @@ function TokenCachePanel(props: {
440
481
  const distData = untrack(() => {
441
482
  let dist: TokenDist = { system: 0, user: 0, agent: 0, toolCall: 0, toolResult: 0, output: 0, apiOutput: 0, apiInput: 0, stepCost: 0 }
442
483
  let hasDistData = false
484
+ const loadedSkills = new Map<string, { name: string; tokens: number }>()
443
485
  try {
444
- const session = props.api.state.session.get(sid), cfg = props.api.state.config as Record<string, unknown>
486
+ const cfg = props.api.state.config as Record<string, unknown>
445
487
  const agentName = String(session?.agent ?? (cfg as any)?.default_agent ?? "build")
446
488
  const agents = cfg?.agent as Record<string, unknown> | undefined
447
489
  const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
@@ -467,6 +509,24 @@ function TokenCachePanel(props: {
467
509
  if (rawInput) dist.toolCall += estimateTokens(rawInput)
468
510
  if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
469
511
  else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
512
+ if (tp.tool === "skill" && tp.state.status === "completed") {
513
+ // TUI SDK strips tool metadata — extract skill name from well-known output format.
514
+ // Cross-validated against api.client.app.skills() when available.
515
+ let name: string | undefined = tp.state.metadata?.name
516
+ if (typeof name !== "string") {
517
+ const m = typeof tp.state.output === "string"
518
+ ? tp.state.output.match(/^#{1,2}\s*Skill:\s*(.+)/m)
519
+ : null
520
+ if (m) name = m[1].trim()
521
+ }
522
+ if (typeof name === "string") {
523
+ const tokens = typeof tp.state.output === "string" ? estimateTokens(tp.state.output) : 0
524
+ const existing = loadedSkills.get(name)
525
+ if (!existing || existing.tokens < tokens) {
526
+ loadedSkills.set(name, { name, tokens })
527
+ }
528
+ }
529
+ }
470
530
  } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
471
531
  else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
472
532
  }
@@ -484,7 +544,8 @@ function TokenCachePanel(props: {
484
544
  hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
485
545
  } catch {}
486
546
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
487
- return { finalDist, finalHasDist }
547
+ const skills = [...loadedSkills.values()]
548
+ return { finalDist, finalHasDist, skills }
488
549
  })
489
550
 
490
551
  setDataSignal({
@@ -493,6 +554,7 @@ function TokenCachePanel(props: {
493
554
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
494
555
  trend, hasTrendData, providerName, sessionHitRate,
495
556
  dist: distData.finalDist, hasDistData: distData.finalHasDist,
557
+ skills: distData.skills, hasSkills: distData.skills.length > 0,
496
558
  })
497
559
  })
498
560
 
@@ -532,6 +594,7 @@ function TokenCachePanel(props: {
532
594
  setDetailOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.detail`, true)))
533
595
  setModelOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.model`, true)))
534
596
  setDistOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.dist`, false)))
597
+ setSkillsOpen(Boolean(props.api.kv.get(`${KV_PREFIX}.skills`, true)))
535
598
  } catch {}
536
599
 
537
600
  // Restore user config (currency, rate, section visibility).
@@ -546,6 +609,7 @@ function TokenCachePanel(props: {
546
609
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)))
547
610
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)))
548
611
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)))
612
+ setSectionSkills(Boolean(props.api.kv.get(`${KV_PREFIX}.section.skills`, true)))
549
613
  const bv = props.api.kv.get<boolean>(`${KV_PREFIX}.border`, true)
550
614
  setBorderVisible(bv !== false)
551
615
  // Restore language preference
@@ -848,9 +912,33 @@ function TokenCachePanel(props: {
848
912
  </Show>
849
913
  </Show>
850
914
  </Show>
915
+
916
+ {/* ── loaded skills (collapsible, default open) ── */}
917
+ <Show when={sectionSkills()}>
918
+ <Show when={data().hasSkills}>
919
+ {<text onMouseUp={() => setSkillsOpen((o) => { const n = !o; persistFold("skills", n); return n })}>
920
+ <span style={{ fg: pal().muted }}>{skillsOpen() ? "\u25bc " : "\u25b6 "}</span>
921
+ <span style={{ fg: pal().primary }}><b>{t().secSkills}</b></span>
922
+ <span style={{ fg: pal().muted }}> ({data().skills.length})</span>
923
+ <span style={{ fg: pal().muted }}>{sep().slice(visualWidth((skillsOpen() ? "\u25bc " : "\u25b6 ") + t().secSkills + ` (${data().skills.length})`))}</span>
924
+ </text>}
925
+ <Show when={skillsOpen()}>
926
+ {data().skills.map((sk: { name: string; tokens: number }) => {
927
+ const rightW = visualWidth(fmt(sk.tokens)) + UNIT_GAP + visualWidth(t().tok)
928
+ const maxLabel = Math.max(4, panelWidth() - gutter() - rightW - 1)
929
+ const label = truncateVisual(sk.name, maxLabel)
930
+ return (
931
+ <text fg={pal().muted}>
932
+ {justify(label, fmt(sk.tokens), t().tok)}
933
+ </text>
934
+ )
935
+ })}
936
+ </Show>
937
+ </Show>
938
+ </Show>
851
939
  </Show>
852
940
  </Show>
853
- </box>
941
+ </box>
854
942
  )
855
943
  }
856
944
 
@@ -883,6 +971,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
883
971
  const [sectionDetail, setSectionDetail] = createSignal(true)
884
972
  const [sectionModel, setSectionModel] = createSignal(true)
885
973
  const [sectionDist, setSectionDist] = createSignal(true)
974
+ const [sectionSkills, setSectionSkills] = createSignal(true)
886
975
  const [borderVisible, setBorderVisible] = createSignal(true)
887
976
  const [langZH, setLangZH] = createSignal(LANG_ZH)
888
977
 
@@ -893,6 +982,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
893
982
  sectionDetail, setSectionDetail,
894
983
  sectionModel, setSectionModel,
895
984
  sectionDist, setSectionDist,
985
+ sectionSkills, setSectionSkills,
896
986
  borderVisible, setBorderVisible,
897
987
  }
898
988
 
@@ -962,6 +1052,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
962
1052
  const detailOn = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
963
1053
  const modelOn = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
964
1054
  const distOn = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1055
+ const skillsOn = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
965
1056
  const borderOn = Boolean(api.kv.get(`${KV_PREFIX}.border`, true))
966
1057
  dialog?.replace(() => (
967
1058
  <api.ui.DialogSelect
@@ -970,6 +1061,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
970
1061
  { title: `Token Detail [${detailOn ? "ON" : "OFF"}]`, value: "detail" },
971
1062
  { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" },
972
1063
  { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" },
1064
+ { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" },
973
1065
  { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" },
974
1066
  ]}
975
1067
  onSelect={(opt) => {
@@ -985,6 +1077,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
985
1077
  if (opt.value === "detail") signals.setSectionDetail(!cur)
986
1078
  if (opt.value === "model") signals.setSectionModel(!cur)
987
1079
  if (opt.value === "dist") signals.setSectionDist(!cur)
1080
+ if (opt.value === "skills") signals.setSectionSkills(!cur)
988
1081
  api.ui.toast({ message: `${opt.value} section ${!cur ? "shown" : "hidden"}` })
989
1082
  }
990
1083
  dialog?.clear()
@@ -1004,9 +1097,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1004
1097
  const detail = Boolean(api.kv.get(`${KV_PREFIX}.section.detail`, true))
1005
1098
  const model = Boolean(api.kv.get(`${KV_PREFIX}.section.model`, true))
1006
1099
  const dist = Boolean(api.kv.get(`${KV_PREFIX}.section.dist`, true))
1100
+ const skills = Boolean(api.kv.get(`${KV_PREFIX}.section.skills`, true))
1007
1101
  api.ui.toast({
1008
1102
  title: "Cache Panel Config",
1009
- message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"}`,
1103
+ message: `Currency: ${sym} | Rate: ${rate} | Detail: ${detail ? "ON" : "OFF"} | Model: ${model ? "ON" : "OFF"} | Dist: ${dist ? "ON" : "OFF"} | Skills: ${skills ? "ON" : "OFF"}`,
1010
1104
  duration: 8000,
1011
1105
  })
1012
1106
  dialog?.clear()
@@ -1037,6 +1131,46 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => {
1037
1131
  ))
1038
1132
  },
1039
1133
  },
1134
+ {
1135
+ title: "Cache: Debug Skills Detection",
1136
+ value: "cache.debug-skills",
1137
+ description: "Dump all tool parts found in the current session for skill detection debugging",
1138
+ slash: { name: "cache-debug-skills" },
1139
+ onSelect: () => {
1140
+ const rt = api.route.current
1141
+ if (rt.name !== "session" || !rt.params) {
1142
+ api.ui.toast({ message: "Please run this command inside a session", variant: "warning" })
1143
+ return
1144
+ }
1145
+ const sid = String(rt.params.sessionID)
1146
+ const msgs = api.state.session.messages(sid)
1147
+ const byTool: Record<string, number> = {}
1148
+ const skillParts: string[] = []
1149
+ for (const msg of msgs) {
1150
+ if (msg.role !== "assistant") continue
1151
+ let parts: readonly any[] = []
1152
+ try { parts = api.state.part(msg.id) } catch {}
1153
+ for (const p of parts) {
1154
+ if (p.type === "tool") {
1155
+ const t = String(p.tool ?? "?")
1156
+ byTool[t] = (byTool[t] ?? 0) + 1
1157
+ if (t === "skill") {
1158
+ const meta = p.state?.metadata
1159
+ const rootMeta = p.metadata
1160
+ 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)}"`)
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ const summary = Object.entries(byTool).map(([k, v]) => `${k}: ${v}`).join(" | ")
1166
+ 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'"
1167
+ api.ui.toast({
1168
+ title: `Tool Summary (${Object.keys(byTool).length} types)`,
1169
+ message: summary + extra,
1170
+ duration: 15000,
1171
+ })
1172
+ },
1173
+ },
1040
1174
  ])
1041
1175
  }
1042
1176