opencode-tokenwatch 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -9,14 +9,14 @@ Real-time token usage, cache analytics & performance dashboard plugin for OpenCo
9
9
  ## Features
10
10
 
11
11
  - **Sidebar panel** — Session-level and per-model real-time stats (requests, tokens, cache, cost)
12
- - **Cache hit rate** — Inline color progress bars with trend indicators (↑/↓) and global weighted hit rate
12
+ - **Cache hit rate** — Per-model tracking with trend indicators (↑/↓) and global weighted total
13
13
  - **Performance metrics** — TTFT / TPS / End-to-end latency + P50/P95/P99 latency percentiles
14
- - **Token distribution** — 5-bucket role breakdown (system / user / toolCall / toolResult / output + other fallback)
15
- - **Error rate tracking** — Detects failed requests (empty token response) and computes real-time error rate
16
- - **Cost display** — Per-model cost (requires provider billing data)
14
+ - **Token distribution** — Per-role breakdown (system / user / tool / output)
15
+ - **Cost tracking** — Per-model cost display (requires provider billing data)
16
+ - **Error rate tracking** — Detects failed requests and computes real-time error rate
17
17
  - **`/usage` command** — HTML Report → JSON Export → Text Report → Settings
18
- - **HTML report** — Interactive ECharts dashboard: token distribution, performance percentiles, TPS horizontal ranking, error rate analysis — auto-opened in browser
19
- - **Persistent stats** — Performance metrics (TPS/TTFT/latency) written to a dedicated JSON file that accumulates forever, unaffected by log rotation
18
+ - **HTML report** — Interactive ECharts dashboard: token trends, performance comparison, TPS ranking, error rate analysis — auto-opened in browser
19
+ - **Persistent stats** — Performance metrics accumulate forever in a dedicated file, unaffected by session resets
20
20
  - **Multi-level collapse** — Panel, models, and sub-blocks collapsible with persisted state
21
21
  - **Language switching** — Auto-detect or manually switch between Chinese and English
22
22
 
@@ -37,32 +37,7 @@ Add to `opencode.json` or `opencode.jsonc`:
37
37
 
38
38
  ## Configuration
39
39
 
40
- ```jsonc
41
- {
42
- "plugin": ["opencode-tokenwatch"],
43
- "pluginConfig": {
44
- "opencode-tokenwatch": {
45
- "sidebar": {
46
- "showPerformance": true,
47
- "showPricing": true,
48
- "showTokenDistribution": true,
49
- "showTrend": true
50
- },
51
- "language": "auto"
52
- }
53
- }
54
- }
55
- ```
56
-
57
- | Option | Type | Default | Description |
58
- |--------|------|---------|-------------|
59
- | `sidebar.showPerformance` | boolean | `true` | Show TTFT/TPS/latency |
60
- | `sidebar.showPricing` | boolean | `true` | Show request cost |
61
- | `sidebar.showTokenDistribution` | boolean | `true` | Show token distribution |
62
- | `sidebar.showTrend` | boolean | `true` | Show trend indicator |
63
- | `language` | `"auto"` / `"zh"` / `"en"` | `"auto"` | UI language |
64
-
65
- Settings can also be toggled via `/usage` → Settings, taking precedence over `pluginConfig`.
40
+ In OpenCode TUI, run `/usage` → **Settings** to interactively toggle display items and switch language. Settings are persisted automatically — no need to edit config files manually.
66
41
 
67
42
  ## Usage
68
43
 
@@ -77,8 +52,8 @@ In OpenCode TUI, run `/usage`:
77
52
 
78
53
  | File | Path | Description |
79
54
  |------|------|-------------|
80
- | JSONL log | `~/.opencode/tokenwatch.jsonl` | Raw per-request log, auto-rotated at 5 MB |
81
- | Aggregated stats | `~/.opencode/tokenwatch-stats.json` | Persistent performance stats, accumulates forever |
55
+ | JSONL log | `~/.opencode/tokenwatch.jsonl` | Raw per-request log |
56
+ | Aggregated stats | `~/.opencode/tokenwatch-stats.json` | Persistent performance stats |
82
57
  | Report output | `~/.opencode/reports/` | HTML / JSON / Markdown reports |
83
58
 
84
59
  ## Requirements
package/README.md CHANGED
@@ -9,14 +9,14 @@ OpenCode CLI 的实时 Token 用量统计、缓存分析与性能指标插件。
9
9
  ## 功能
10
10
 
11
11
  - **侧边栏面板** — 会话级与按模型的实时统计(请求数、Token、缓存、成本)
12
- - **缓存命中率** — 模型行内彩色进度条,带趋势指示器(↑/↓),全局总计行显示**加权命中率**
13
- - **性能指标** — TTFT / TPS / 端到端延迟 + P50/P95/P99 延迟分位数
14
- - **Token 分布** — 5 桶角色分解(system / user / toolCall / toolResult / output + other 兜底)
15
- - **错误率统计**识别并统计失败请求(空 Token 响应),实时计算错误率
16
- - **成本展示**按模型显示 cost(需 provider 返回计费数据)
12
+ - **缓存命中率** — 按模型实时追踪,带趋势指示器(↑/↓)与全局加权总计
13
+ - **性能指标** — TTFT / TPS / 端到端延迟,含 P50/P95/P99 延迟分位数
14
+ - **Token 分布** — 按角色(system / user / tool / output)分解用量占比
15
+ - **成本追踪**按模型实时显示请求成本(需 provider 返回计费数据)
16
+ - **错误率统计**识别并统计失败请求,实时计算错误率
17
17
  - **`/usage` 命令** — HTML 报告 → JSON 导出 → 文本报告 → 设置
18
- - **HTML 报告** — 交互式 ECharts 仪表盘:Token 分布、性能分位数、TPS 水平排名、错误率分析,自动在浏览器打开
19
- - **持久化统计** — 性能指标(TPS/TTFT/延迟)写入独立 JSON 文件,永久累积,不受日志轮转影响
18
+ - **HTML 报告** — 交互式 ECharts 仪表盘:Token 趋势、性能对比、TPS 排名、错误率分析,自动在浏览器打开
19
+ - **持久化统计** — 性能指标永久累积写入独立文件,历史数据不受会话重置影响
20
20
  - **多级折叠** — 面板、模型、子区块均可折叠,状态持久化
21
21
  - **语言切换** — 中英双语,跟随系统或手动切换
22
22
 
@@ -37,32 +37,7 @@ npm install opencode-tokenwatch
37
37
 
38
38
  ## 配置
39
39
 
40
- ```jsonc
41
- {
42
- "plugin": ["opencode-tokenwatch"],
43
- "pluginConfig": {
44
- "opencode-tokenwatch": {
45
- "sidebar": {
46
- "showPerformance": true,
47
- "showPricing": true,
48
- "showTokenDistribution": true,
49
- "showTrend": true
50
- },
51
- "language": "auto"
52
- }
53
- }
54
- }
55
- ```
56
-
57
- | 配置项 | 类型 | 默认值 | 说明 |
58
- |--------|------|--------|------|
59
- | `sidebar.showPerformance` | boolean | `true` | 显示 TTFT/TPS/延迟 |
60
- | `sidebar.showPricing` | boolean | `true` | 显示请求成本 |
61
- | `sidebar.showTokenDistribution` | boolean | `true` | 显示 Token 分布 |
62
- | `sidebar.showTrend` | boolean | `true` | 显示趋势指示器 |
63
- | `language` | `"auto"` / `"zh"` / `"en"` | `"auto"` | 界面语言 |
64
-
65
- 运行时也可通过 `/usage` → 设置 调整,优先级高于 `pluginConfig`。
40
+ 在 OpenCode TUI 中输入 `/usage` → **设置**,可交互式开关各显示项和切换界面语言,配置自动持久化,无需手动编辑配置文件。
66
41
 
67
42
  ## 用法
68
43
 
@@ -77,8 +52,8 @@ npm install opencode-tokenwatch
77
52
 
78
53
  | 文件 | 路径 | 说明 |
79
54
  |------|------|------|
80
- | JSONL 日志 | `~/.opencode/tokenwatch.jsonl` | 原始请求日志,超 5MB 自动轮转 |
81
- | 聚合统计 | `~/.opencode/tokenwatch-stats.json` | 持久化性能统计,永久累积 |
55
+ | JSONL 日志 | `~/.opencode/tokenwatch.jsonl` | 原始请求日志 |
56
+ | 聚合统计 | `~/.opencode/tokenwatch-stats.json` | 持久化性能统计 |
82
57
  | 报告输出 | `~/.opencode/reports/` | HTML / JSON / Markdown 报告 |
83
58
 
84
59
  ## 系统要求
package/dist/commands.jsx CHANGED
@@ -61,6 +61,9 @@ function computePercentile(sortedArr, p) {
61
61
  function aggregatePerfStats(logs) {
62
62
  const map = new Map();
63
63
  for (const entry of logs) {
64
+ // 过滤掉全零无效条目:token 均为 0 表示请求失败或未完成
65
+ if (entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens === 0)
66
+ continue;
64
67
  const key = entry.model;
65
68
  let s = map.get(key);
66
69
  if (!s) {
@@ -23,7 +23,8 @@ function cacheHitRate(input, cacheRead) {
23
23
  return cacheRead / (input + cacheRead);
24
24
  }
25
25
  function sortModelsByUsage(models) {
26
- return [...models].sort((a, b) => b.totalTokens - a.totalTokens);
26
+ // 过滤掉 totalTokens=0 的无效模型条目(如会话失败导致全为 0 的记录)
27
+ return [...models].filter(m => m.totalTokens > 0).sort((a, b) => b.totalTokens - a.totalTokens);
27
28
  }
28
29
  function renderMeta(data) {
29
30
  const m = data.meta;
@@ -221,10 +222,13 @@ function renderModelChart(chart) {
221
222
  }`;
222
223
  }
223
224
  function renderScatterChartInit(data) {
224
- if (data.perfSummary.length === 0)
225
+ // 过滤掉全零无效条目:无请求或无任何 token 的模型
226
+ const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
227
+ (p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
228
+ if (validPerf.length === 0)
225
229
  return "";
226
230
  // 按 TPS 降序排列(null TPS 放末尾),让最快的模型显示在最上方
227
- const sorted = [...data.perfSummary].sort((a, b) => {
231
+ const sorted = [...validPerf].sort((a, b) => {
228
232
  if (a.avgTPS == null && b.avgTPS == null)
229
233
  return 0;
230
234
  if (a.avgTPS == null)
@@ -334,8 +338,12 @@ function providerBorderColor(provider) {
334
338
  const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800", };
335
339
  return colors[provider] || "#2A2A35";
336
340
  }
341
+ /** 渲染 Provider 卡片,按 token 总量降序,最多显示 10 个 */
337
342
  function renderProviderCards(data) {
338
- return data.providers.map(p => {
343
+ const sorted = [...data.providers].sort((a, b) => b.totalTokens - a.totalTokens);
344
+ const top = sorted.slice(0, 10);
345
+ const remaining = sorted.length - 10;
346
+ const cards = top.map(p => {
339
347
  const modelCount = data.models.filter(m => m.provider === p.provider).length;
340
348
  const perfItems = data.perfSummary.filter(ps => ps.providerID === p.provider);
341
349
  let ttftSum = 0, ttftReqs = 0;
@@ -364,11 +372,21 @@ function renderProviderCards(data) {
364
372
  <div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
365
373
  </div>`;
366
374
  }).join("\n");
375
+ const moreHint = remaining > 0
376
+ ? `<div class="provider-more">+ ${remaining} more provider${remaining > 1 ? 's' : ''} not shown</div>`
377
+ : '';
378
+ return cards + moreHint;
367
379
  }
368
- function renderDataTable(data) {
369
- const rows = sortModelsByUsage(data.models).map(m => {
380
+ /**
381
+ * 渲染 Model Analytics 区块:三个表格合并为 Tab 切换展示,各自带分页控件。
382
+ * Tab 1: Usage Breakdown — Token/成本用量分解
383
+ * Tab 2: Latency Percentiles — TTFT/E2E 分位数
384
+ * Tab 3: Failed Requests — 失败请求明细(无数据时不显示此 Tab)
385
+ */
386
+ function renderModelAnalyticsSection(data) {
387
+ // ─── Tab 1: Usage Breakdown ───────────────────────────────────────────────
388
+ const usageRows = sortModelsByUsage(data.models).map(m => {
370
389
  const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
371
- const hitRatePct = fmtPercent(hitRate);
372
390
  const hitColor = hitRate >= 0.85 ? 'var(--cache)' : hitRate >= 0.70 ? 'var(--tps)' : 'var(--output)';
373
391
  const perf = data.perfSummary.find(p => p.model === `${m.provider}/${m.model}`);
374
392
  const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + 'ms' : '—';
@@ -382,39 +400,18 @@ function renderDataTable(data) {
382
400
  <td>${fmtTokens(m.inputTokens)}</td>
383
401
  <td>${fmtTokens(m.outputTokens)}</td>
384
402
  <td>${fmtTokens(m.cacheRead)}</td>
385
- <td style="color:${hitColor};font-weight:600">${hitRatePct}</td>
403
+ <td style="color:${hitColor};font-weight:600">${fmtPercent(hitRate)}</td>
386
404
  <td>${ttft}</td>
387
405
  <td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
388
406
  <td>${tps}</td>
389
407
  <td>${fmtCost(m.totalCost)}</td>
390
408
  </tr>`;
391
409
  }).join("\n");
392
- return `<table class="data-table">
393
- <thead>
394
- <tr>
395
- <th>Model</th>
396
- <th>Provider</th>
397
- <th>Req</th>
398
- <th>Total</th>
399
- <th>Input</th>
400
- <th>Output</th>
401
- <th>Cache</th>
402
- <th>Hit Rate</th>
403
- <th>Avg TTFT</th>
404
- <th>P95 TTFT</th>
405
- <th>TPS</th>
406
- <th>Cost</th>
407
- </tr>
408
- </thead>
409
- <tbody>${rows}</tbody>
410
- </table>`;
411
- }
412
- /** 渲染 P50/P95/P99 延迟分位数表格 */
413
- function renderPerfPercentileTable(data) {
414
- if (data.perfSummary.length === 0)
415
- return '';
410
+ // ─── Tab 2: Latency Percentiles ───────────────────────────────────────────
411
+ const validPerf = data.perfSummary.filter(p => p.requestCount > 0 &&
412
+ (p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
416
413
  const fmtMs = (v) => v != null ? v.toFixed(0) + 'ms' : '—';
417
- const rows = data.perfSummary.map(p => {
414
+ const perfRows = validPerf.map(p => {
418
415
  const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? 'var(--cache)'
419
416
  : p.cacheHitRate != null && p.cacheHitRate >= 70 ? 'var(--tps)' : 'var(--output)';
420
417
  return `<tr>
@@ -431,67 +428,97 @@ function renderPerfPercentileTable(data) {
431
428
  <td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + '%' : '—'}</td>
432
429
  </tr>`;
433
430
  }).join("\n");
434
- return `
435
- <section class="section">
436
- <h2 class="section-title">Performance Latency Percentiles</h2>
437
- <table class="data-table">
438
- <thead>
439
- <tr>
440
- <th>Model</th>
441
- <th>Req</th>
442
- <th>Avg TTFT</th>
443
- <th>P50 TTFT</th>
444
- <th>P95 TTFT</th>
445
- <th>P99 TTFT</th>
446
- <th>Avg E2E</th>
447
- <th>P50 E2E</th>
448
- <th>P95 E2E</th>
449
- <th>P99 E2E</th>
450
- <th>Cache Hit</th>
451
- </tr>
452
- </thead>
453
- <tbody>${rows}</tbody>
454
- </table>
455
- </section>`;
456
- }
457
- /** 渲染失败请求统计区域 */
458
- function renderErrorStatsSection(data) {
431
+ // ─── Tab 3: Failed Requests ───────────────────────────────────────────────
459
432
  const errors = data.errors;
460
- if (!errors || errors.failedCount === 0)
461
- return '';
462
- const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
463
- const rateColor = errors.errorRate >= 0.05 ? 'var(--output)'
464
- : errors.errorRate > 0 ? 'var(--tps)' : 'var(--cache)';
465
- const rows = errors.byModel
466
- .filter(m => m.failed > 0)
467
- .map(m => {
468
- const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
469
- return `<tr>
470
- <td>${m.provider}</td>
471
- <td>${m.model}</td>
472
- <td>${m.total}</td>
473
- <td style="color:var(--output)">${m.failed}</td>
474
- <td style="color:var(--tps)">${m.total - m.failed}</td>
475
- <td style="color:${errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)'}">${modelRate}</td>
476
- </tr>`;
477
- }).join('\n');
478
- return `
479
- <section class="section">
480
- <h2 class="section-title">Failed Requests
481
- <span style="margin-left:12px;font-size:0.85em;color:${rateColor}">
482
- overall error rate: ${errorRatePct} (${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total)
483
- </span>
484
- </h2>
485
- <table class="data-table">
486
- <thead>
487
- <tr>
433
+ const hasErrors = !!(errors && errors.failedCount > 0);
434
+ let errorTabBtn = '';
435
+ let errorTabContent = '';
436
+ if (hasErrors) {
437
+ const errorRatePct = (errors.errorRate * 100).toFixed(2) + '%';
438
+ const rateColor = errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)';
439
+ const cellColor = errors.errorRate >= 0.05 ? 'var(--output)' : 'var(--tps)';
440
+ const errorRows = errors.byModel
441
+ .filter(m => m.failed > 0)
442
+ .map(m => {
443
+ const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + '%' : '—';
444
+ return `<tr>
445
+ <td>${m.provider}</td>
446
+ <td>${m.model}</td>
447
+ <td>${m.total}</td>
448
+ <td style="color:var(--output)">${m.failed}</td>
449
+ <td style="color:var(--tps)">${m.total - m.failed}</td>
450
+ <td style="color:${cellColor}">${modelRate}</td>
451
+ </tr>`;
452
+ }).join('\n');
453
+ errorTabBtn = `
454
+ <button class="tab-btn" data-mtab="errors" onclick="switchModelTab('errors')">
455
+ Failed Requests <span style="color:var(--output);margin-left:4px;font-size:0.85em">(${errors.failedCount})</span>
456
+ </button>`;
457
+ errorTabContent = `
458
+ <div id="model-tab-errors" class="tab-content">
459
+ <p style="font-size:12px;color:${rateColor};padding:8px 0 6px">
460
+ Overall error rate: <strong>${errorRatePct}</strong> &mdash;
461
+ ${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total
462
+ </p>
463
+ <table id="errors-table" class="data-table">
464
+ <thead><tr>
488
465
  <th>Provider</th><th>Model</th><th>Total</th>
489
466
  <th>Failed</th><th>Success</th><th>Error Rate</th>
490
- </tr>
491
- </thead>
492
- <tbody>${rows}</tbody>
493
- </table>
494
- </section>`;
467
+ </tr></thead>
468
+ <tbody>${errorRows}</tbody>
469
+ </table>
470
+ <div class="pagination-ctrl" id="errors-table-ctrl">
471
+ <button class="page-btn" id="errors-table-prev">← Prev</button>
472
+ <span class="page-info" id="errors-table-info"></span>
473
+ <button class="page-btn" id="errors-table-next">Next →</button>
474
+ </div>
475
+ </div>`;
476
+ }
477
+ return `
478
+ <div class="section">
479
+ <div class="section-title">Model Analytics</div>
480
+ <div class="tab-bar">
481
+ <button class="tab-btn active" data-mtab="usage" onclick="switchModelTab('usage')">Usage Breakdown</button>
482
+ <button class="tab-btn" data-mtab="perf" onclick="switchModelTab('perf')">Latency Percentiles</button>
483
+ ${errorTabBtn}
484
+ </div>
485
+
486
+ <div id="model-tab-usage" class="tab-content active">
487
+ <table id="usage-table" class="data-table">
488
+ <thead><tr>
489
+ <th>Model</th><th>Provider</th><th>Req</th><th>Total</th>
490
+ <th>Input</th><th>Output</th><th>Cache</th><th>Hit Rate</th>
491
+ <th>Avg TTFT</th><th>P95 TTFT</th><th>TPS</th><th>Cost</th>
492
+ </tr></thead>
493
+ <tbody>${usageRows}</tbody>
494
+ </table>
495
+ <div class="pagination-ctrl" id="usage-table-ctrl">
496
+ <button class="page-btn" id="usage-table-prev">← Prev</button>
497
+ <span class="page-info" id="usage-table-info"></span>
498
+ <button class="page-btn" id="usage-table-next">Next →</button>
499
+ </div>
500
+ </div>
501
+
502
+ <div id="model-tab-perf" class="tab-content">
503
+ ${validPerf.length > 0 ? `
504
+ <table id="perf-table" class="data-table">
505
+ <thead><tr>
506
+ <th>Model</th><th>Req</th>
507
+ <th>Avg TTFT</th><th>P50 TTFT</th><th>P95 TTFT</th><th>P99 TTFT</th>
508
+ <th>Avg E2E</th><th>P50 E2E</th><th>P95 E2E</th><th>P99 E2E</th>
509
+ <th>Cache Hit</th>
510
+ </tr></thead>
511
+ <tbody>${perfRows}</tbody>
512
+ </table>
513
+ <div class="pagination-ctrl" id="perf-table-ctrl">
514
+ <button class="page-btn" id="perf-table-prev">← Prev</button>
515
+ <span class="page-info" id="perf-table-info"></span>
516
+ <button class="page-btn" id="perf-table-next">Next →</button>
517
+ </div>` : '<div class="empty-state">No performance data available for this period.</div>'}
518
+ </div>
519
+
520
+ ${errorTabContent}
521
+ </div>`;
495
522
  }
496
523
  function renderDailyTrendInit(data) {
497
524
  const days = data.daily.slice().reverse().map(d => d.day);
@@ -643,12 +670,11 @@ export function generateUsageHtml(data) {
643
670
  const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
644
671
  const scatterChartJs = renderScatterChartInit(data);
645
672
  const providerStr = renderProviderCards(data);
646
- const tableStr = renderDataTable(data);
647
- const perfPercentileStr = renderPerfPercentileTable(data);
648
- const errorStatsStr = renderErrorStatsSection(data);
673
+ const modelAnalyticsStr = renderModelAnalyticsSection(data);
649
674
  const dailyChartJs = renderDailyTrendInit(data);
650
675
  const heatmapJs = renderHeatmapInit(data);
651
- const hasPerf = data.perfSummary.length > 0;
676
+ const hasPerf = data.perfSummary.some(p => p.requestCount > 0 &&
677
+ (p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite) > 0);
652
678
  const jsonData = JSON.stringify(data);
653
679
  return `<!DOCTYPE html>
654
680
  <html lang="en">
@@ -729,6 +755,7 @@ export function generateUsageHtml(data) {
729
755
  .provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
730
756
  .provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
731
757
  .provider-stat .stat-label { color: var(--text-dim); }
758
+ .provider-more { color: var(--text-dim); font-size: 11px; padding: 10px 4px 0; grid-column: 1 / -1; }
732
759
 
733
760
  .data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
734
761
  .data-table th {
@@ -746,6 +773,22 @@ export function generateUsageHtml(data) {
746
773
  }
747
774
  .data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
748
775
 
776
+ .pagination-ctrl {
777
+ display: none; align-items: center; gap: 14px; justify-content: center;
778
+ padding: 14px 0 4px;
779
+ }
780
+ .page-btn {
781
+ background: var(--card); border: 1px solid var(--border); color: var(--text);
782
+ padding: 5px 16px; border-radius: 4px; cursor: pointer;
783
+ font-size: 12px; font-family: 'Inter', sans-serif; transition: border-color 0.15s, color 0.15s;
784
+ }
785
+ .page-btn:hover:not(:disabled) { border-color: var(--input); color: var(--input); }
786
+ .page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
787
+ .page-info {
788
+ color: var(--text-dim); font-size: 12px;
789
+ font-family: 'JetBrains Mono', monospace; min-width: 110px; text-align: center;
790
+ }
791
+
749
792
  .empty-state {
750
793
  background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
751
794
  padding: 40px; text-align: center; color: var(--text-dim);
@@ -778,7 +821,7 @@ export function generateUsageHtml(data) {
778
821
 
779
822
  <div class="section">
780
823
  <div class="section-title">Model Comparison Matrix</div>
781
- ${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with >=1M tokens in this period.</div>'}
824
+ ${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with &ge;1M tokens in this period.</div>'}
782
825
  </div>
783
826
 
784
827
  <div class="section">
@@ -786,14 +829,7 @@ export function generateUsageHtml(data) {
786
829
  <div class="provider-row">${providerStr}</div>
787
830
  </div>
788
831
 
789
- <div class="section">
790
- <div class="section-title">Detailed Data Grid</div>
791
- ${tableStr}
792
- </div>
793
-
794
- ${perfPercentileStr}
795
-
796
- ${errorStatsStr}
832
+ ${modelAnalyticsStr}
797
833
 
798
834
  <div class="section">
799
835
  <div class="section-title">Efficiency vs Cost</div>
@@ -814,9 +850,9 @@ export function generateUsageHtml(data) {
814
850
  </div>
815
851
  </div>
816
852
 
817
- <div class="footer">
818
- Generated by TokenWatch &middot; Data: SQLite + JSONL &middot; Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
819
- </div>
853
+ <div class="footer">
854
+ Generated by TokenWatch &middot; Data: SQLite + JSONL &middot; Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
855
+ </div>
820
856
  </div>
821
857
 
822
858
  <script id="report-data" type="application/json">${jsonData}</script>
@@ -830,9 +866,10 @@ var fmt = function(v) {
830
866
  return String(v);
831
867
  };
832
868
 
869
+ // Usage Timeline tab switcher
833
870
  window.switchTab = function(name) {
834
871
  document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
835
- document.querySelectorAll('.tab-btn').forEach(function(el) { el.classList.remove('active'); });
872
+ document.querySelectorAll('.tab-btn[data-tab]').forEach(function(el) { el.classList.remove('active'); });
836
873
  document.getElementById('tab-' + name).classList.add('active');
837
874
  document.querySelector('[data-tab="' + name + '"]').classList.add('active');
838
875
  setTimeout(function() {
@@ -841,6 +878,50 @@ window.switchTab = function(name) {
841
878
  }, 50);
842
879
  };
843
880
 
881
+ // Model Analytics tab switcher
882
+ window.switchModelTab = function(name) {
883
+ document.querySelectorAll('[data-mtab]').forEach(function(el) { el.classList.remove('active'); });
884
+ ['model-tab-usage', 'model-tab-perf', 'model-tab-errors'].forEach(function(id) {
885
+ var el = document.getElementById(id);
886
+ if (el) el.classList.remove('active');
887
+ });
888
+ var activeTab = document.getElementById('model-tab-' + name);
889
+ if (activeTab) activeTab.classList.add('active');
890
+ var activeBtn = document.querySelector('[data-mtab="' + name + '"]');
891
+ if (activeBtn) activeBtn.classList.add('active');
892
+ };
893
+
894
+ // Generic paginator: hide/show tbody rows, show prev/next controls
895
+ function initPaginator(tableId, pageSize) {
896
+ var tbody = document.querySelector('#' + tableId + ' tbody');
897
+ if (!tbody) return;
898
+ var rows = Array.from(tbody.querySelectorAll('tr'));
899
+ if (rows.length <= pageSize) return; // 行数不超过一页时无需分页
900
+ var totalPages = Math.ceil(rows.length / pageSize);
901
+ var cur = 1;
902
+
903
+ function render() {
904
+ rows.forEach(function(r, i) {
905
+ r.style.display = (i >= (cur - 1) * pageSize && i < cur * pageSize) ? '' : 'none';
906
+ });
907
+ var info = document.getElementById(tableId + '-info');
908
+ if (info) info.textContent = 'Page ' + cur + ' / ' + totalPages + ' (' + rows.length + ' rows)';
909
+ var prevEl = document.getElementById(tableId + '-prev');
910
+ var nextEl = document.getElementById(tableId + '-next');
911
+ if (prevEl) prevEl.disabled = cur === 1;
912
+ if (nextEl) nextEl.disabled = cur === totalPages;
913
+ }
914
+
915
+ var prevEl = document.getElementById(tableId + '-prev');
916
+ var nextEl = document.getElementById(tableId + '-next');
917
+ if (prevEl) prevEl.addEventListener('click', function() { if (cur > 1) { cur--; render(); } });
918
+ if (nextEl) nextEl.addEventListener('click', function() { if (cur < totalPages) { cur++; render(); } });
919
+
920
+ var ctrl = document.getElementById(tableId + '-ctrl');
921
+ if (ctrl) ctrl.style.display = 'flex';
922
+ render();
923
+ }
924
+
844
925
  ${modelChartJs}
845
926
  ${scatterChartJs}
846
927
  ${dailyChartJs}
@@ -864,6 +945,9 @@ document.addEventListener('DOMContentLoaded', function() {
864
945
  ${hasPerf ? 'initScatterChart();' : ''}
865
946
  initDailyChart();
866
947
  initHeatmapChart();
948
+ initPaginator('usage-table', 10);
949
+ initPaginator('perf-table', 10);
950
+ initPaginator('errors-table', 10);
867
951
  });
868
952
 
869
953
  window.addEventListener('resize', function() {
@@ -43,6 +43,11 @@ class PerfTracker {
43
43
  const cacheRead = tokens?.cache?.read ?? 0;
44
44
  const cacheWrite = tokens?.cache?.write ?? 0;
45
45
  const cost = info.cost ?? 0;
46
+ // 过滤全零 token 的失败请求,不写入日志和统计,防止污染数据
47
+ if (inputTokens + outputTokens + cacheRead + cacheWrite === 0) {
48
+ this.firstPartTimes.delete(messageID);
49
+ return;
50
+ }
46
51
  const firstPart = this.firstPartTimes.get(messageID) ?? null;
47
52
  const latencyMs = completed - created;
48
53
  const ttftMs = firstPart !== null ? firstPart - created : null;
package/dist/sidebar.jsx CHANGED
@@ -149,11 +149,13 @@ export function TokenWatchPanel(props) {
149
149
  // ── 数据聚合 ──
150
150
  const modelStats = createMemo(() => {
151
151
  const map = new Map();
152
- for (const msg of props.allTokenMessages()) {
152
+ const msgs = props.allTokenMessages();
153
+ for (let i = 0; i < msgs.length; i++) {
154
+ const msg = msgs[i];
153
155
  const key = `${msg.providerID}/${msg.modelID}`;
154
156
  let e = map.get(key);
155
157
  if (!e) {
156
- e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
158
+ e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0, lastMessageIndex: -1 };
157
159
  map.set(key, e);
158
160
  }
159
161
  e.totalInput += msg.inputTokens;
@@ -163,8 +165,13 @@ export function TokenWatchPanel(props) {
163
165
  e.cacheWrite += msg.cacheWrite;
164
166
  e.totalCost += msg.cost;
165
167
  e.requestCount++;
168
+ e.lastMessageIndex = i; // 追踪该模型最后一条消息的索引(越大 = 越近调用)
166
169
  }
167
- return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
170
+ return Array.from(map.entries())
171
+ // 过滤掉全零无效模型(如会话失败导致 token 全为 0 的记录)
172
+ .filter(([, s]) => s.totalInput + s.totalOutput + s.totalReasoning + s.cacheRead + s.cacheWrite > 0)
173
+ // 按最近调用时间降序:最后一条消息在数组中索引越大越靠前
174
+ .sort((a, b) => b[1].lastMessageIndex - a[1].lastMessageIndex);
168
175
  });
169
176
  const sessionTotals = createMemo(() => {
170
177
  let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
@@ -442,8 +449,14 @@ export function TokenWatchPanel(props) {
442
449
  ? RGBA.fromInts(63, 185, 80, 255)
443
450
  : RGBA.fromInts(244, 67, 54, 255);
444
451
  };
445
- // 模型名处理:假如总长超过22字符,且格式为 厂商/模型厂商/模型名称,去除中间模型厂商,只留 provider/modelname 格式
446
- let fullTitle = `${stat.providerID}/${stat.modelID}`;
452
+ // 供应商名称截断:超过12字符则省略
453
+ const MAX_PROVIDER_LEN = 12;
454
+ let providerDisplay = stat.providerID;
455
+ if (providerDisplay.length > MAX_PROVIDER_LEN) {
456
+ providerDisplay = providerDisplay.slice(0, MAX_PROVIDER_LEN - 1) + "…";
457
+ }
458
+ let fullTitle = `${providerDisplay}/${stat.modelID}`;
459
+ // 去除中间段:格式为 厂商/模型厂商/模型名称 时只留 provider/modelname
447
460
  if (fullTitle.length > 22) {
448
461
  const parts = fullTitle.split("/");
449
462
  if (parts.length >= 3) {
package/dist/tui.jsx CHANGED
@@ -108,7 +108,8 @@ const tui = async (api) => {
108
108
  if (msg.role !== "assistant")
109
109
  continue;
110
110
  const tokens = msg.tokens;
111
- if (!tokens)
111
+ // 与 message.updated 处理器保持一致:过滤 total=0 的失败请求
112
+ if (!tokens || (tokens.total ?? 0) === 0)
112
113
  continue;
113
114
  const id = msg.id;
114
115
  const idx = next.findIndex(m => m.id === id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-tokenwatch",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",