opencode-visual-cache 1.2.9 → 1.2.10

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
@@ -134,6 +134,8 @@ npm install -g opencode-visual-cache@latest
134
134
 
135
135
  通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。
136
136
 
137
+ > **关于 Token 分布数值**:分布面板中"总计"为最后一次 API 调用的精确 token 数,"系统提示"/"用户"等分项为字符级 BPE 估算值。分项之和通常小于总计,差值主要来自 OpenCode 运行时注入的系统提示组成部分,包括环境信息、Skill 目录、工具 Schema 定义等(详见 [`system.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/system.ts)、[`tools.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/tools.ts))。这些内容不在 agent 配置的 `prompt` 字段中,因此插件无法估算,属于预期行为。
138
+
137
139
  ---
138
140
 
139
141
  ## 5. 更新
@@ -1 +1 @@
1
- export declare const PLUGIN_VERSION = "1.2.9";
1
+ export declare const PLUGIN_VERSION = "1.2.10";
package/dist/_version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION = "1.2.9";
2
+ export const PLUGIN_VERSION = "1.2.10";
package/dist/index.js CHANGED
@@ -216,7 +216,7 @@ function fmt(n) {
216
216
  if (n >= 1_000_000)
217
217
  return (n / 1_000_000).toFixed(1) + "M";
218
218
  if (n >= 10_000)
219
- return Math.round(n / 1_000) + "K";
219
+ return (n / 1_000).toFixed(1) + "K";
220
220
  return n.toLocaleString("en-US");
221
221
  }
222
222
  function num(v) {
@@ -256,16 +256,19 @@ function estimateTokens(text) {
256
256
  else
257
257
  ascii++;
258
258
  }
259
- // Tighten the ASCII ratio for structured content where punctuation is
260
- // token-dense. JSON key-value patterns and code keywords are strong
261
- // signals that the default 4:1 ratio will materially under-estimate.
259
+ // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
260
+ // ASCII chars/token for both JSON and source code close to prose.
261
+ // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
262
+ // payloads, and systematically over-estimated token counts.
262
263
  const trimmed = text.trimStart();
263
- const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("["))
264
+ // Strip markdown code-fence prefix so that ```json … is detected as JSON
265
+ const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "");
266
+ const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
264
267
  && /"[^"]+"\s*:/.test(text);
265
268
  const codeLike = !jsonLike
266
269
  && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
267
- const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
268
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
270
+ const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4;
271
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0));
269
272
  }
270
273
  const CURRENCIES = {
271
274
  USD: "$", CNY: "¥", EUR: "€", JPY: "JP¥", GBP: "£", KRW: "₩",
@@ -376,6 +379,7 @@ function TokenCachePanel(props) {
376
379
  const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : "";
377
380
  if (sysPrompt)
378
381
  dist.system = estimateTokens(sysPrompt);
382
+ let lastAssMsg;
379
383
  for (const msg of msgs) {
380
384
  if (msg.role === "user") {
381
385
  const um = msg;
@@ -409,14 +413,9 @@ function TokenCachePanel(props) {
409
413
  const tp = p;
410
414
  let rawInput = "";
411
415
  try {
412
- rawInput = tp.state.raw ?? JSON.stringify(tp.state.input);
413
- }
414
- catch {
415
- try {
416
- rawInput = JSON.stringify(tp.state);
417
- }
418
- catch { }
416
+ rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "");
419
417
  }
418
+ catch { }
420
419
  if (rawInput)
421
420
  dist.toolCall += estimateTokens(rawInput);
422
421
  if (tp.state.status === "completed") {
@@ -436,19 +435,23 @@ function TokenCachePanel(props) {
436
435
  const sub = p;
437
436
  dist.agent += estimateTokens(sub.prompt || sub.description || "");
438
437
  }
439
- else if (p.type === "step-finish") {
440
- const sf = p;
441
- dist.apiInput += sf.tokens?.input ?? 0;
442
- dist.apiOutput += sf.tokens?.output ?? 0;
443
- }
444
438
  }
445
439
  }
446
440
  }
447
- const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult;
448
- const overhead = Math.max(0, dist.apiInput - totalInput);
449
- if (overhead >= 50)
450
- dist.system += overhead;
451
- hasDistData = totalInput > 0 || dist.apiOutput > 0 || dist.apiInput > 0;
441
+ // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
442
+ for (let i = msgs.length - 1; i >= 0; i--) {
443
+ if (msgs[i].role !== "assistant")
444
+ continue;
445
+ const t = msgs[i].tokens;
446
+ if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) {
447
+ lastAssMsg = msgs[i];
448
+ break;
449
+ }
450
+ }
451
+ // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
452
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read);
453
+ dist.apiOutput = num(lastAssMsg?.tokens?.output);
454
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0;
452
455
  }
453
456
  catch { }
454
457
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist();
@@ -626,7 +629,7 @@ function TokenCachePanel(props) {
626
629
  // boxEl.width may be undefined before the first measurement — guard with 0
627
630
  const w = boxEl ? Math.max(MIN_PANEL_WIDTH, boxEl.width ?? 0) : DEFAULT_PANEL_WIDTH;
628
631
  setPanelWidth((prev) => (prev === w ? prev : w));
629
- }, children: [_jsxs("text", { onMouseUp: () => setOpen((o) => { const n = !o; persistFold("open", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: open() ? "\u25bc " : "\u25b6 " }), _jsxs("span", { style: { fg: pal().primary }, children: [_jsx("b", { children: T.title }), _jsx(Show, { when: open(), children: _jsxs("span", { style: { fg: pal().muted }, children: [" (v", PLUGIN_VERSION, ")"] }) })] }), _jsxs(Show, { when: !open() && data().hasData, children: [_jsxs(Show, { when: data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded + " " + trendLabel(data().trend)))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] }), _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] })] }), _jsxs(Show, { when: !data().hasTrendData, children: [_jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - HEADER_PREFIX - visualWidth(T.title) - visualWidth(pct() + " " + T.hitFolded))) }), _jsxs("span", { style: { fg: hitColor() }, children: [pct(), " ", T.hitFolded] })] })] })] }), _jsx(Show, { when: open(), children: _jsxs(Show, { when: data().hasData, fallback: _jsxs(_Fragment, { children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: "> " }), _jsx("span", { style: { fg: pal().muted }, children: T.noData })] })] }), children: [_jsx("text", { fg: pal().muted, children: sep() }), _jsxs("text", { children: [_jsxs("span", { style: { fg: pal().text }, children: [T.hit, " "] }), _jsxs("span", { style: { fg: hitColor() }, children: ["[", bar(), "] "] }), _jsx("span", { style: { fg: pal().text }, children: pct() }), _jsx(Show, { when: data().hasTrendData, children: _jsxs("span", { style: { fg: data().trend !== 0 ? (data().trend > 0 ? pal().success : pal().error) : pal().text }, children: [" ", trendLabel(data().trend)] }) })] }), _jsx("text", { fg: pal().muted, children: justify(T.totalHit, (Math.floor(data().sessionHitRate * 10) / 10).toFixed(1) + "%") }), _jsxs(Show, { when: sectionDetail(), children: [_jsxs("text", { onMouseUp: () => setDetailOpen((o) => { const n = !o; persistFold("detail", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: detailOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secDetail }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((detailOpen() ? "\u25bc " : "\u25b6 ") + T.secDetail)) })] }), _jsxs(Show, { when: detailOpen(), children: [_jsx(Show, { when: data().read > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.read, fmt(data().read), T.tok) }) }), _jsx(Show, { when: data().write > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.write, fmt(data().write), T.tok) }) }), _jsx("text", { fg: pal().muted, children: justify(T.miss, fmt(data().freshInput), T.tok) }), _jsx("text", { fg: pal().muted, children: justify(T.out, fmt(data().output), T.tok) }), _jsx(Show, { when: data().saved > 0, children: _jsxs("text", { children: [_jsx("span", { style: { fg: pal().muted }, children: T.saved }), _jsx("span", { children: " ".repeat(Math.max(1, panelWidth() - gutter() - visualWidth(T.saved) - visualWidth("~" + fmtCost(data().saved, currencySymbol(), exchangeRate())))) }), _jsxs("span", { style: { fg: pal().success }, children: ["~", fmtCost(data().saved, currencySymbol(), exchangeRate())] })] }) })] })] }), _jsxs(Show, { when: sectionModel(), children: [_jsxs("text", { onMouseUp: () => setModelOpen((o) => { const n = !o; persistFold("model", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: modelOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.secModel }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((modelOpen() ? "\u25bc " : "\u25b6 ") + T.secModel)) })] }), _jsxs(Show, { when: modelOpen(), children: [_jsx("text", { fg: pal().text, children: justify(T.cost, fmtCost(data().cost, currencySymbol(), exchangeRate())) }), _jsx(Show, { when: data().providerName, children: _jsx("text", { fg: pal().muted, children: justify(T.provider, data().providerName) }) }), _jsx("text", { fg: pal().muted, children: justify(T.model, data().model) }), _jsxs(Show, { when: data().hasPricing, children: [_jsx("text", { fg: pal().muted, children: justify(T.rate, currencySymbol() + (data().inputRate * exchangeRate()).toFixed(2) + "/M " + T.inputRate) }), _jsx(Show, { when: data().cacheReadRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheReadRate * exchangeRate()).toFixed(2) + "/M " + T.cacheRate) }) }), _jsx(Show, { when: data().cacheWriteRate > 0, children: _jsx("text", { fg: pal().muted, children: justify("", currencySymbol() + (data().cacheWriteRate * exchangeRate()).toFixed(2) + "/M " + T.writeRate) }) })] })] })] }), _jsx(Show, { when: sectionDist(), children: _jsxs(Show, { when: data().hasDistData, children: [_jsxs("text", { onMouseUp: () => setDistOpen((o) => { const n = !o; persistFold("dist", n); return n; }), children: [_jsx("span", { style: { fg: pal().muted }, children: distOpen() ? "\u25bc " : "\u25b6 " }), _jsx("span", { style: { fg: pal().primary }, children: _jsx("b", { children: T.distTitle }) }), _jsx("span", { style: { fg: pal().muted }, children: sep().slice(visualWidth((distOpen() ? "\u25bc " : "\u25b6 ") + T.distTitle)) })] }), _jsxs(Show, { when: distOpen(), children: [_jsx(Show, { when: data().dist.system > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distSys, fmt(data().dist.system), T.tok) }) }), _jsx(Show, { when: data().dist.user > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distUser, fmt(data().dist.user), T.tok) }) }), _jsx(Show, { when: data().dist.agent > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distAgent, fmt(data().dist.agent), T.tok) }) }), _jsx(Show, { when: data().dist.toolCall > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distTool, fmt(data().dist.toolCall), T.tok) }) }), _jsx(Show, { when: data().dist.toolResult > 0, children: _jsx("text", { fg: pal().muted, children: justify(T.distRes, fmt(data().dist.toolResult), T.tok) }) }), _jsx("text", { fg: pal().text, children: justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok) })] })] }) })] }) })] }));
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) })] })] }) })] }) })] }));
630
633
  }
631
634
  // ---------------------------------------------------------------------------
632
635
  // Plugin entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.2.9",
3
+ "version": "1.2.10",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.2.9";
2
+ export const PLUGIN_VERSION="1.2.10";
package/src/index.tsx CHANGED
@@ -241,7 +241,7 @@ function progressBar(percent: number, width: number): string {
241
241
 
242
242
  function fmt(n: number): string {
243
243
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
244
- if (n >= 10_000) return Math.round(n / 1_000) + "K"
244
+ if (n >= 10_000) return (n / 1_000).toFixed(1) + "K"
245
245
  return n.toLocaleString("en-US")
246
246
  }
247
247
 
@@ -277,17 +277,20 @@ function estimateTokens(text: string): number {
277
277
  else ascii++
278
278
  }
279
279
 
280
- // Tighten the ASCII ratio for structured content where punctuation is
281
- // token-dense. JSON key-value patterns and code keywords are strong
282
- // signals that the default 4:1 ratio will materially under-estimate.
280
+ // Real BPE tokenizers (cl100k_base, o200k_base) average ~3.5-4.0
281
+ // ASCII chars/token for both JSON and source code close to prose.
282
+ // The old 2.0 / 2.5 ratios matched minified-JS extremes, not typical
283
+ // payloads, and systematically over-estimated token counts.
283
284
  const trimmed = text.trimStart()
284
- const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("["))
285
+ // Strip markdown code-fence prefix so that ```json … is detected as JSON
286
+ const strippedFence = trimmed.replace(/^\x60{3}\w*\s*\n?/, "")
287
+ const jsonLike = (strippedFence.startsWith("{") || strippedFence.startsWith("["))
285
288
  && /"[^"]+"\s*:/.test(text)
286
289
  const codeLike = !jsonLike
287
290
  && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text)
288
291
 
289
- const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4
290
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5))
292
+ const asciiPerToken = jsonLike ? 3.5 : codeLike ? 3.5 : 4
293
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.0))
291
294
  }
292
295
 
293
296
  interface TokenDist {
@@ -436,6 +439,7 @@ function TokenCachePanel(props: {
436
439
  const agentCfg = agents?.[agentName] as Record<string, unknown> | undefined
437
440
  const sysPrompt = typeof agentCfg?.prompt === "string" ? agentCfg.prompt : ""
438
441
  if (sysPrompt) dist.system = estimateTokens(sysPrompt)
442
+ let lastAssMsg: AssistantMessage | undefined
439
443
  for (const msg of msgs) {
440
444
  if (msg.role === "user") {
441
445
  const um = msg as UserMessage; if (um.system) dist.system += estimateTokens(um.system)
@@ -445,24 +449,31 @@ function TokenCachePanel(props: {
445
449
  else if (p.type === "file") { const fp = p as any; if (fp.source?.text?.value) dist.user += estimateTokens(fp.source.text.value) }
446
450
  }
447
451
  } else if (msg.role === "assistant") {
448
- const am = msg as AssistantMessage; dist.output += num(am.tokens?.output)
452
+ const am = msg as AssistantMessage
453
+ dist.output += num(am.tokens?.output)
449
454
  let parts: readonly Part[] = []; try { parts = props.api.state.part(msg.id) } catch {}
450
455
  for (const p of parts) {
451
456
  if (p.type === "tool") {
452
457
  const tp = p as any; let rawInput = ""
453
- try { rawInput = tp.state.raw ?? JSON.stringify(tp.state.input) } catch { try { rawInput = JSON.stringify(tp.state) } catch {} }
458
+ try { rawInput = tp.state.raw ?? (tp.state.input != null ? JSON.stringify(tp.state.input) : "") } catch {}
454
459
  if (rawInput) dist.toolCall += estimateTokens(rawInput)
455
460
  if (tp.state.status === "completed") { const c = tp.state; if (c.output) dist.toolResult += estimateTokens(c.output) }
456
461
  else if (tp.state.status === "error") { const e = tp.state; if (e.error) dist.toolResult += estimateTokens(e.error) }
457
462
  } else if (p.type === "reasoning") dist.agent += estimateTokens((p as any).text)
458
463
  else if (p.type === "subtask") { const sub = p as any; dist.agent += estimateTokens(sub.prompt || sub.description || "") }
459
- else if (p.type === "step-finish") { const sf = p as any; dist.apiInput += sf.tokens?.input ?? 0; dist.apiOutput += sf.tokens?.output ?? 0 }
460
464
  }
461
465
  }
462
466
  }
463
- const totalInput = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult
464
- const overhead = Math.max(0, dist.apiInput - totalInput); if (overhead >= 50) dist.system += overhead
465
- hasDistData = totalInput > 0 || dist.apiOutput > 0 || dist.apiInput > 0
467
+ // 从后往前找最后一条有 token 数据的 assistant 消息(避免取到 streaming 中未填充的消息)
468
+ for (let i = msgs.length - 1; i >= 0; i--) {
469
+ if (msgs[i].role !== "assistant") continue
470
+ const t = (msgs[i] as AssistantMessage).tokens
471
+ if (t && (t.input > 0 || (t.cache?.read ?? 0) > 0)) { lastAssMsg = msgs[i] as AssistantMessage; break }
472
+ }
473
+ // 取最后一条有数据消息的总输入(含缓存读)作为当前 context 大小
474
+ dist.apiInput = num(lastAssMsg?.tokens?.input) + num(lastAssMsg?.tokens?.cache?.read)
475
+ dist.apiOutput = num(lastAssMsg?.tokens?.output)
476
+ hasDistData = dist.system + dist.user + dist.agent + dist.toolCall + dist.toolResult > 0 || dist.apiOutput > 0 || dist.apiInput > 0
466
477
  } catch {}
467
478
  const finalDist = hasDistData ? dist : lastDist(), finalHasDist = hasDistData || lastHasDist()
468
479
  return { finalDist, finalHasDist }
@@ -479,7 +490,7 @@ function TokenCachePanel(props: {
479
490
 
480
491
  const data = createMemo(() => {
481
492
  return dataSignal()
482
- })
493
+ })
483
494
 
484
495
  // Persist the last valid distribution so that data() can fall back
485
496
  // to it while api.state.part() is re-hydrating after a view switch.
@@ -819,7 +830,7 @@ function TokenCachePanel(props: {
819
830
  </text>
820
831
  </Show>
821
832
  <text fg={pal().text}>
822
- {justify(T.distTotal, fmt(data().dist.system + data().dist.user + data().dist.agent + data().dist.toolCall + data().dist.toolResult), T.tok)}
833
+ {justify(T.distTotal, fmt(data().dist.apiInput), T.tok)}
823
834
  </text>
824
835
  </Show>
825
836
  </Show>