opencode-rag-plugin 1.10.6 → 1.12.1

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.
Files changed (65) hide show
  1. package/ReadMe.md +44 -6
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/api.js.map +1 -1
  5. package/dist/chunker/image.d.ts +14 -0
  6. package/dist/chunker/image.js +356 -0
  7. package/dist/chunker/image.js.map +1 -0
  8. package/dist/chunker/loader.js +5 -0
  9. package/dist/chunker/loader.js.map +1 -1
  10. package/dist/cli.js +220 -2
  11. package/dist/cli.js.map +1 -1
  12. package/dist/core/config.d.ts +17 -0
  13. package/dist/core/config.js +30 -5
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/interfaces.d.ts +12 -0
  16. package/dist/core/resolve-api-key.js +3 -0
  17. package/dist/core/resolve-api-key.js.map +1 -1
  18. package/dist/core/runtime-overrides.d.ts +6 -0
  19. package/dist/core/runtime-overrides.js +20 -1
  20. package/dist/core/runtime-overrides.js.map +1 -1
  21. package/dist/describer/describer.js +1 -1
  22. package/dist/describer/describer.js.map +1 -1
  23. package/dist/embedder/health.d.ts +18 -0
  24. package/dist/embedder/health.js +291 -0
  25. package/dist/embedder/health.js.map +1 -0
  26. package/dist/eval/index.d.ts +3 -0
  27. package/dist/eval/index.js +2 -0
  28. package/dist/eval/index.js.map +1 -1
  29. package/dist/eval/run-token-test.d.ts +7 -0
  30. package/dist/eval/run-token-test.js +288 -0
  31. package/dist/eval/run-token-test.js.map +1 -0
  32. package/dist/eval/token-analysis.d.ts +111 -0
  33. package/dist/eval/token-analysis.js +286 -0
  34. package/dist/eval/token-analysis.js.map +1 -0
  35. package/dist/eval/token-counter.d.ts +41 -0
  36. package/dist/eval/token-counter.js +102 -0
  37. package/dist/eval/token-counter.js.map +1 -0
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.js +1 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/indexer.d.ts +1 -1
  42. package/dist/indexer.js +65 -13
  43. package/dist/indexer.js.map +1 -1
  44. package/dist/opencode/tools.js +12 -5
  45. package/dist/opencode/tools.js.map +1 -1
  46. package/dist/plugin.js +71 -29
  47. package/dist/plugin.js.map +1 -1
  48. package/dist/retriever/keyword-index.d.ts +1 -0
  49. package/dist/retriever/keyword-index.js +11 -0
  50. package/dist/retriever/keyword-index.js.map +1 -1
  51. package/dist/retriever/retriever.d.ts +1 -0
  52. package/dist/retriever/retriever.js +49 -7
  53. package/dist/retriever/retriever.js.map +1 -1
  54. package/dist/tui.js +54 -5
  55. package/dist/tui.js.map +1 -1
  56. package/dist/web/api.d.ts +8 -0
  57. package/dist/web/api.js +81 -0
  58. package/dist/web/api.js.map +1 -1
  59. package/dist/web/server.js +31 -0
  60. package/dist/web/server.js.map +1 -1
  61. package/dist/web/ui/app.css +1 -0
  62. package/dist/web/ui/github-dark.css +118 -0
  63. package/dist/web/ui/highlight.min.js +1213 -0
  64. package/dist/web/ui/index.html +504 -125
  65. package/package.json +8 -2
@@ -4,34 +4,15 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>OpenCodeRAG - Vector Database Explorer</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
9
- <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
10
- <script>
11
- tailwind.config = {
12
- darkMode: 'class',
13
- theme: {
14
- extend: {
15
- colors: {
16
- brand: { 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2' }
17
- }
18
- }
19
- }
20
- }
21
- </script>
7
+ <link rel="stylesheet" href="/ui/app.css">
8
+ <link rel="stylesheet" href="/ui/github-dark.css">
9
+ <script src="/ui/highlight.min.js"></script>
22
10
  <style>
23
- body { background: #0f172a; color: #e2e8f0; }
24
- .scrollbar-thin::-webkit-scrollbar { width: 6px; }
25
- .scrollbar-thin::-webkit-scrollbar-track { background: #1e293b; }
26
- .scrollbar-thin::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
27
- .chunk-row { border-left: 2px solid transparent; }
28
- .chunk-row:hover { background: #1e293b; }
29
- .chunk-row.selected { background: #1e293b; border-left-color: #06b6d4; }
30
- .file-item:hover { background: #1e293b; }
31
- .file-item.active { background: #1e293b; border-left: 2px solid #06b6d4; }
32
- .kpi-card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; }
33
- .nav-btn.active { background: #06b6d4; color: #0f172a; }
34
- pre code.hljs { background: transparent; padding: 0; }
11
+ .chart-svg text { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
12
+ .chart-svg rect { transition: opacity 0.15s; }
13
+ .chart-svg rect:hover { opacity: 0.85; }
14
+ .chart-svg circle { transition: r 0.15s; }
15
+ .chart-svg circle:hover { r: 5; }
35
16
  </style>
36
17
  </head>
37
18
  <body class="h-screen flex flex-col overflow-hidden">
@@ -42,7 +23,6 @@
42
23
  <button id="nav-dashboard" class="nav-btn active px-3 py-1 rounded text-sm font-medium transition">Dashboard</button>
43
24
  <button id="nav-chunks" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Chunks</button>
44
25
  <button id="nav-files" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Files</button>
45
- <button id="nav-compare" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Compare</button>
46
26
  <button id="nav-evaluate" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Evaluate</button>
47
27
  </nav>
48
28
  <div class="flex-1"></div>
@@ -75,8 +55,7 @@
75
55
  <div id="chunks-detail" class="w-1/2 overflow-y-auto scrollbar-thin pl-1"></div>
76
56
  </div>
77
57
  <div id="view-files" class="h-full overflow-y-auto scrollbar-thin"></div>
78
- <div id="view-compare" class="h-full overflow-y-auto scrollbar-thin"></div>
79
- <div id="view-evaluate" class="hidden h-full overflow-y-auto scrollbar-thin"></div>
58
+ <div id="view-evaluate" class="hidden h-full flex flex-col"></div>
80
59
  </main>
81
60
  </div>
82
61
 
@@ -103,6 +82,9 @@
103
82
  async evalSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id)); return r.json(); },
104
83
  async evalDeleteSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); return r.json(); },
105
84
  async evalCompare(a, b) { const p = new URLSearchParams({a, b}); const r = await fetch('/api/eval/compare?' + p); return r.json(); },
85
+ async evalAnalysis(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id) + '/analysis'); return r.json(); },
86
+ async evalTokenCompare(a, b) { const p = new URLSearchParams({a, b}); const r = await fetch('/api/eval/token-compare?' + p); return r.json(); },
87
+ async evalProjectSavings(params) { const r = await fetch('/api/eval/project-savings', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(params) }); return r.json(); },
106
88
  };
107
89
 
108
90
  const state = {
@@ -112,7 +94,7 @@
112
94
  selectedChunkId: null,
113
95
  chunkOffset: 0,
114
96
  chunkLimit: 50,
115
- compareIds: new Set(),
97
+
116
98
  collapsedDirs: new Set(),
117
99
  stats: null,
118
100
  files: [],
@@ -165,6 +147,213 @@
165
147
  });
166
148
  }
167
149
 
150
+ // ── SVG Chart Helpers ──────────────────────────────────────────────
151
+
152
+ const CHART_COLORS = {
153
+ input: '#3b82f6', // blue-500
154
+ output: '#a855f7', // purple-500
155
+ rag: '#06b6d4', // cyan-500
156
+ reasoning: '#f59e0b', // amber-500
157
+ cache: '#64748b', // slate-500
158
+ cost: '#22c55e', // green-500
159
+ time: '#f97316', // orange-500
160
+ sessionA: '#3b82f6',
161
+ sessionB: '#06b6d4',
162
+ };
163
+
164
+ function svgAttr(attrs) {
165
+ return Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ');
166
+ }
167
+
168
+ function svgRect(x, y, w, h, fill, extra = '') {
169
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" ${extra}/>`;
170
+ }
171
+
172
+ function svgText(x, y, text, extra = '') {
173
+ return `<text x="${x}" y="${y}" ${extra}>${escapeHtml(String(text))}</text>`;
174
+ }
175
+
176
+ function svgLine(x1, y1, x2, y2, extra = '') {
177
+ return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${extra}/>`;
178
+ }
179
+
180
+ function svgCircle(cx, cy, r, extra = '') {
181
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" ${extra}/>`;
182
+ }
183
+
184
+ function svgPath(d, extra = '') {
185
+ return `<path d="${d}" ${extra}/>`;
186
+ }
187
+
188
+ function svgDonutArc(cx, cy, r, startAngle, endAngle, fill, innerR) {
189
+ if (endAngle - startAngle >= 359.99) {
190
+ return svgCircle(cx, cy, r, `fill="none" stroke="${fill}" stroke-width="${r - (innerR || r * 0.6)}"`) +
191
+ svgCircle(cx, cy, innerR || r * 0.6, `fill="#0f172a"`);
192
+ }
193
+ const toRad = a => (a - 90) * Math.PI / 180;
194
+ const x1 = cx + r * Math.cos(toRad(startAngle));
195
+ const y1 = cy + r * Math.sin(toRad(startAngle));
196
+ const x2 = cx + r * Math.cos(toRad(endAngle));
197
+ const y2 = cy + r * Math.sin(toRad(endAngle));
198
+ const ix1 = cx + (innerR || r * 0.6) * Math.cos(toRad(endAngle));
199
+ const iy1 = cy + (innerR || r * 0.6) * Math.sin(toRad(endAngle));
200
+ const ix2 = cx + (innerR || r * 0.6) * Math.cos(toRad(startAngle));
201
+ const iy2 = cy + (innerR || r * 0.6) * Math.sin(toRad(startAngle));
202
+ const large = endAngle - startAngle > 180 ? 1 : 0;
203
+ const d = `M${x1},${y1} A${r},${r} 0 ${large} 1 ${x2},${y2} L${ix1},${iy1} A${innerR || r * 0.6},${innerR || r * 0.6} 0 ${large} 0 ${ix2},${iy2} Z`;
204
+ return svgPath(d, `fill="${fill}"`);
205
+ }
206
+
207
+ function chartLegend(items) {
208
+ return `<div class="flex flex-wrap gap-3 mt-2">${items.map(([label, color]) =>
209
+ `<div class="flex items-center gap-1.5 text-xs text-slate-400">
210
+ <span class="inline-block w-3 h-3 rounded-sm" style="background:${color}"></span>${escapeHtml(label)}
211
+ </div>`
212
+ ).join('')}</div>`;
213
+ }
214
+
215
+ // ── Vertical Bar Chart: Session Comparison ─────────────────────────
216
+
217
+ function renderComparisonBars(a, b) {
218
+ const aName = truncate(a.title || a.sessionID.slice(0, 8), 14);
219
+ const bName = truncate(b.title || b.sessionID.slice(0, 8), 14);
220
+
221
+ const aIn = a.totalTokens.input + a.totalTokens.cacheRead;
222
+ const bIn = b.totalTokens.input + b.totalTokens.cacheRead;
223
+ const aTotalTokens = aIn + a.totalTokens.output + a.totalTokens.reasoning;
224
+ const bTotalTokens = bIn + b.totalTokens.output + b.totalTokens.reasoning;
225
+
226
+ const aRagToolCount = (a.toolCallCounts?.search_semantic || 0) + (a.toolCallCounts?.get_file_skeleton || 0) + (a.toolCallCounts?.find_usages || 0);
227
+ const bRagToolCount = (b.toolCallCounts?.search_semantic || 0) + (b.toolCallCounts?.get_file_skeleton || 0) + (b.toolCallCounts?.find_usages || 0);
228
+
229
+ const metrics = [
230
+ { label: 'Total Tokens', aVal: aTotalTokens, bVal: bTotalTokens, color: CHART_COLORS.input, stacked: [
231
+ { label: 'Input', color: CHART_COLORS.input, aFn: () => aIn - a.ragContextTokens, bFn: () => bIn - b.ragContextTokens },
232
+ { label: 'RAG Ctx', color: CHART_COLORS.rag, aFn: () => a.ragContextTokens, bFn: () => b.ragContextTokens },
233
+ { label: 'Output', color: CHART_COLORS.output, aFn: () => a.totalTokens.output, bFn: () => b.totalTokens.output },
234
+ { label: 'Reasoning', color: CHART_COLORS.reasoning, aFn: () => a.totalTokens.reasoning, bFn: () => b.totalTokens.reasoning },
235
+ ]},
236
+ { label: 'Cost', aVal: a.totalCost, bVal: b.totalCost, color: CHART_COLORS.cost, format: formatCost },
237
+ { label: 'Avg Response', aVal: a.avgResponseTimeMs || 0, bVal: b.avgResponseTimeMs || 0, color: CHART_COLORS.time, format: formatMs },
238
+ { label: 'Messages', aVal: a.messageCount, bVal: b.messageCount, color: '#94a3b8' },
239
+ { label: 'RAG Injections', aVal: a.ragContextCount, bVal: b.ragContextCount, color: CHART_COLORS.rag },
240
+ { label: 'RAG Tool Calls', aVal: aRagToolCount, bVal: bRagToolCount, color: CHART_COLORS.rag },
241
+ ];
242
+
243
+ const n = metrics.length;
244
+ const svgW = 700, svgH = 260;
245
+ const padL = 10, padR = 20, padTop = 30, padBot = 50;
246
+ const chartW = svgW - padL - padR;
247
+ const chartH = svgH - padTop - padBot;
248
+ const groupW = chartW / n;
249
+ const barW = Math.min(groupW * 0.3, 40);
250
+ const barGap = 4;
251
+ const baseline = padTop + chartH;
252
+
253
+ const maxGlobal = Math.max(...metrics.map(m => Math.max(m.aVal, m.bVal)), 1);
254
+
255
+ const gridLines = [0.25, 0.5, 0.75, 1].map(pct => {
256
+ const y = baseline - chartH * pct;
257
+ return svgLine(padL, y, svgW - padR, y, 'stroke="#1e293b" stroke-width="1"');
258
+ }).join('');
259
+
260
+ const axisLine = svgLine(padL, baseline, svgW - padR, baseline, 'stroke="#334155" stroke-width="1"');
261
+
262
+ const barGroups = metrics.map((m, i) => {
263
+ const cx = padL + groupW * i + groupW / 2;
264
+ const aBarX = cx - barW - barGap / 2;
265
+ const bBarX = cx + barGap / 2;
266
+ const scale = chartH / maxGlobal;
267
+ const aH = m.aVal * scale;
268
+ const bH = m.bVal * scale;
269
+
270
+ const fmt = m.format || formatTokens;
271
+ const aLabel = fmt(m.aVal);
272
+ const bLabel = fmt(m.bVal);
273
+
274
+ let bars;
275
+ if (m.stacked) {
276
+ const stackScale = chartH / maxGlobal;
277
+ let aStackY = baseline, bStackY = baseline;
278
+ const aStackBars = m.stacked.filter(s => s.aFn() > 0).map(s => {
279
+ const val = s.aFn();
280
+ const h = val * stackScale;
281
+ aStackY -= h;
282
+ return svgRect(aBarX, aStackY, barW, h, s.color, `rx="2"`);
283
+ }).join('');
284
+ const bStackBars = m.stacked.filter(s => s.bFn() > 0).map(s => {
285
+ const val = s.bFn();
286
+ const h = val * stackScale;
287
+ bStackY -= h;
288
+ return svgRect(bBarX, bStackY, barW, h, s.color, `rx="2"`);
289
+ }).join('');
290
+ bars = aStackBars + bStackBars;
291
+ } else {
292
+ bars = svgRect(aBarX, baseline - aH, barW, aH, m.color, `rx="2"`) +
293
+ svgRect(bBarX, baseline - bH, barW, bH, m.color === CHART_COLORS.cost ? '#16a34a' : m.color === CHART_COLORS.time ? '#ea580c' : CHART_COLORS.sessionB, `rx="2" opacity="0.7"`);
294
+ }
295
+
296
+ return `
297
+ ${bars}
298
+ <text x="${cx}" y="${baseline + 14}" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="inherit">${escapeHtml(m.label)}</text>
299
+ ${m.aVal > 0 ? svgText(aBarX + barW / 2, baseline - aH - 4, aLabel, 'text-anchor="middle" fill="#94a3b8" font-size="8" font-family="inherit"') : ''}
300
+ ${m.bVal > 0 ? svgText(bBarX + barW / 2, baseline - bH - 4, bLabel, 'text-anchor="middle" fill="#94a3b8" font-size="8" font-family="inherit"') : ''}
301
+ `;
302
+ }).join('');
303
+
304
+ return `
305
+ <div class="bg-slate-900 rounded-lg border border-slate-700 p-4 mb-4">
306
+ <h4 class="text-sm font-semibold text-white mb-3">Session Comparison</h4>
307
+ <svg width="100%" viewBox="0 0 ${svgW} ${svgH}" class="chart-svg">
308
+ ${gridLines}${axisLine}${barGroups}
309
+ </svg>
310
+ <div class="flex gap-6 mt-1 justify-center">
311
+ ${chartLegend([[aName, CHART_COLORS.sessionA], [bName, CHART_COLORS.sessionB]])}
312
+ </div>
313
+ </div>`;
314
+ }
315
+
316
+ // ── Donut Chart: Token Composition (Session Detail) ────────────────
317
+
318
+ function renderTokenDonut(s) {
319
+ const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
320
+ const segments = [
321
+ { label: 'Input (non-RAG)', value: totalIn - s.ragContextTokens, color: CHART_COLORS.input },
322
+ { label: 'RAG Context', value: s.ragContextTokens, color: CHART_COLORS.rag },
323
+ { label: 'Output', value: s.totalTokens.output, color: CHART_COLORS.output },
324
+ { label: 'Reasoning', value: s.totalTokens.reasoning, color: CHART_COLORS.reasoning },
325
+ { label: 'Cache Read', value: s.totalTokens.cacheRead, color: CHART_COLORS.cache },
326
+ ].filter(s => s.value > 0);
327
+
328
+ const total = segments.reduce((sum, s) => sum + s.value, 0) || 1;
329
+ const cx = 90, cy = 90, r = 75, innerR = 48;
330
+
331
+ let angle = 0;
332
+ const arcs = segments.map(seg => {
333
+ const sweep = (seg.value / total) * 360;
334
+ const arc = svgDonutArc(cx, cy, r, angle, angle + sweep, seg.color, innerR);
335
+ angle += sweep;
336
+ return arc;
337
+ }).join('');
338
+
339
+ const pctLabel = total >= 1000000 ? (total / 1000000).toFixed(1) + 'M' : total >= 1000 ? (total / 1000).toFixed(1) + 'k' : String(total);
340
+
341
+ return `
342
+ <div class="flex items-center gap-6 mb-4">
343
+ <div class="shrink-0">
344
+ <svg width="180" height="180" viewBox="0 0 180 180" class="chart-svg">
345
+ ${arcs}
346
+ <text x="${cx}" y="${cy - 4}" text-anchor="middle" fill="white" font-size="16" font-weight="bold" font-family="inherit">${pctLabel}</text>
347
+ <text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="#64748b" font-size="9" font-family="inherit">tokens</text>
348
+ </svg>
349
+ </div>
350
+ <div class="flex-1">
351
+ ${chartLegend(segments.map(s => [`${s.label}: ${formatTokens(s.value)} (${((s.value / total) * 100).toFixed(1)}%)`, s.color]))}
352
+ </div>
353
+ </div>`;
354
+ }
355
+
356
+
168
357
  async function renderDashboard() {
169
358
  const [stats, files] = await Promise.all([API.stats(), API.files()]);
170
359
  state.stats = stats;
@@ -235,7 +424,6 @@
235
424
  <table class="w-full text-sm">
236
425
  <thead>
237
426
  <tr class="bg-slate-800 text-slate-400 text-xs">
238
- <th class="px-3 py-2 text-left w-8"></th>
239
427
  <th class="px-3 py-2 text-left">File</th>
240
428
  <th class="px-3 py-2 text-left w-20">Lang</th>
241
429
  <th class="px-3 py-2 text-left">Description</th>
@@ -244,11 +432,9 @@
244
432
  <tbody>
245
433
  ${data.chunks.map((c, i) => {
246
434
  const id = c.id || `chunk-${i}`;
247
- const checked = state.compareIds.has(id) ? 'checked' : '';
248
435
  const selected = state.selectedChunkId === id ? ' selected' : '';
249
436
  return `
250
437
  <tr class="chunk-row${selected} border-t border-slate-800 cursor-pointer" data-id="${id}" data-index="${i}">
251
- <td class="px-3 py-2"><input type="checkbox" class="compare-check rounded" data-id="${id}" ${checked}></td>
252
438
  <td class="px-3 py-2 text-yellow-400 font-mono text-xs">${truncate(c.filePath, 40)}:${c.startLine}-${c.endLine}</td>
253
439
  <td class="px-3 py-2">${langBadge(c.language)}</td>
254
440
  <td class="px-3 py-2 text-slate-400 text-xs">${truncate(c.description || '', 50)}</td>
@@ -267,7 +453,6 @@
267
453
  // Row click -> select and show detail
268
454
  $$('.chunk-row').forEach(row => {
269
455
  row.addEventListener('click', (e) => {
270
- if (e.target.type === 'checkbox') return;
271
456
  const id = row.dataset.id;
272
457
  const idx = parseInt(row.dataset.index, 10);
273
458
  if (!id) return;
@@ -278,14 +463,6 @@
278
463
  });
279
464
  });
280
465
 
281
- $$('.compare-check').forEach(cb => {
282
- cb.addEventListener('change', (e) => {
283
- const id = e.target.dataset.id;
284
- if (e.target.checked) state.compareIds.add(id);
285
- else state.compareIds.delete(id);
286
- });
287
- });
288
-
289
466
  const prevBtn = $('#prev-page');
290
467
  const nextBtn = $('#next-page');
291
468
  if (prevBtn) prevBtn.addEventListener('click', () => { state.chunkOffset = Math.max(0, state.chunkOffset - state.chunkLimit); renderChunks(); });
@@ -511,52 +688,7 @@
511
688
  });
512
689
  }
513
690
 
514
- async function renderCompare() {
515
- const el = $('#view-compare');
516
- const ids = [...state.compareIds];
517
-
518
- if (ids.length === 0) {
519
- el.innerHTML = `
520
- <div class="text-center py-20">
521
- <h2 class="text-lg font-semibold text-slate-400 mb-2">No chunks selected</h2>
522
- <p class="text-sm text-slate-500">Go to the Chunks view, select 2-3 chunks using checkboxes, then come back here.</p>
523
- </div>`;
524
- return;
525
- }
526
-
527
- if (ids.length > 3) {
528
- el.innerHTML = `
529
- <div class="text-center py-20">
530
- <h2 class="text-lg font-semibold text-slate-400 mb-2">Too many selected</h2>
531
- <p class="text-sm text-slate-500">Select at most 3 chunks to compare. Currently selected: ${ids.length}</p>
532
- </div>`;
533
- return;
534
- }
535
-
536
- const data = await API.compare(ids);
537
-
538
- el.innerHTML = `
539
- <div class="flex items-center gap-4 mb-4">
540
- <h2 class="text-lg font-semibold text-white">Compare</h2>
541
- <span class="text-sm text-slate-400">${data.chunks.length} chunks</span>
542
- <button id="clear-compare" class="text-xs text-slate-500 hover:text-white">Clear selection</button>
543
- </div>
544
- <div class="grid gap-4" style="grid-template-columns: repeat(${data.chunks.length}, 1fr)">
545
- ${data.chunks.map(c => `
546
- <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
547
- <div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
548
- <div class="text-xs text-yellow-400 font-mono">${c.filePath}:${c.startLine}-${c.endLine}</div>
549
- <div class="flex items-center gap-2 mt-1">${langBadge(c.language)}</div>
550
- </div>
551
- ${c.description ? `<div class="px-3 py-2 text-xs text-slate-400 border-b border-slate-800">${c.description}</div>` : ''}
552
- <pre class="p-3 overflow-x-auto text-xs max-h-[600px] overflow-y-auto"><code class="language-${c.language}">${escapeHtml(c.content || '')}</code></pre>
553
- </div>
554
- `).join('')}
555
- </div>`;
556
691
 
557
- el.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b));
558
- $('#clear-compare')?.addEventListener('click', () => { state.compareIds.clear(); renderCompare(); });
559
- }
560
692
 
561
693
  async function selectChunkFromSearch(id) {
562
694
  showView('chunks');
@@ -641,7 +773,7 @@
641
773
  showView('files');
642
774
  renderFiles();
643
775
  });
644
- $('#nav-compare')?.addEventListener('click', () => { showView('compare'); renderCompare(); });
776
+
645
777
  $('#nav-evaluate')?.addEventListener('click', () => { showView('evaluate'); renderEvaluate(); });
646
778
 
647
779
  // ── Evaluate View ──────────────────────────────────────────────────
@@ -686,13 +818,15 @@
686
818
 
687
819
  if (sessions.length === 0) {
688
820
  el.innerHTML = `
689
- <div class="text-center py-20">
690
- <div class="text-4xl mb-3">&#128202;</div>
691
- <h2 class="text-lg font-semibold text-slate-400 mb-2">No sessions recorded yet</h2>
692
- <p class="text-sm text-slate-500 max-w-md mx-auto">
693
- Sessions are logged automatically when the OpenCodeRAG plugin is active.
694
- Use OpenCode normally every conversation is captured here with token usage, timing, and RAG context data.
695
- </p>
821
+ <div class="text-center flex-1 flex items-center justify-center">
822
+ <div>
823
+ <div class="text-4xl mb-3">&#128202;</div>
824
+ <h2 class="text-lg font-semibold text-slate-400 mb-2">No sessions recorded yet</h2>
825
+ <p class="text-sm text-slate-500 max-w-md mx-auto">
826
+ Sessions are logged automatically when the OpenCodeRAG plugin is active.
827
+ Use OpenCode normally — every conversation is captured here with token usage, timing, and RAG context data.
828
+ </p>
829
+ </div>
696
830
  </div>`;
697
831
  return;
698
832
  }
@@ -700,16 +834,17 @@
700
834
  const selected = state.evalSelectedSessions;
701
835
 
702
836
  el.innerHTML = `
703
- <div class="flex items-center gap-4 mb-4">
704
- <h2 class="text-lg font-semibold text-white">Evaluate</h2>
705
- <span class="text-sm text-slate-400">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
706
- <div class="flex-1"></div>
707
- <button id="eval-compare-btn" class="px-3 py-1 bg-brand-600 rounded text-sm font-medium hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" ${selected.size === 2 ? '' : 'disabled'}>Compare Selected (2)</button>
708
- <button id="eval-select-all" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Select All</button>
709
- <button id="eval-clear-sel" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Clear</button>
710
- </div>
837
+ <div id="eval-top" class="h-1/2 overflow-y-auto scrollbar-thin border-b border-slate-700">
838
+ <div class="flex items-center gap-4 mb-4">
839
+ <h2 class="text-lg font-semibold text-white">Evaluate</h2>
840
+ <span class="text-sm text-slate-400">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
841
+ <div class="flex-1"></div>
842
+ <button id="eval-compare-btn" class="px-3 py-1 bg-brand-600 rounded text-sm font-medium hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" ${selected.size === 2 ? '' : 'disabled'}>Compare Selected (2)</button>
843
+ <button id="eval-select-all" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Select All</button>
844
+ <button id="eval-clear-sel" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Clear</button>
845
+ </div>
711
846
 
712
- <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden mb-6">
847
+ <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
713
848
  <table class="w-full text-sm">
714
849
  <thead>
715
850
  <tr class="bg-slate-800 text-slate-400 text-xs">
@@ -748,10 +883,66 @@
748
883
  }).join('')}
749
884
  </tbody>
750
885
  </table>
886
+ </div>
751
887
  </div>
752
888
 
753
- <div id="eval-detail-area"></div>
754
- <div id="eval-compare-area"></div>
889
+ <div id="eval-bottom" class="h-1/2 overflow-y-auto scrollbar-thin">
890
+ <div id="eval-compare-area"></div>
891
+ <div id="eval-detail-area"></div>
892
+
893
+ <div id="eval-projection-area" class="mt-4">
894
+ <details class="group">
895
+ <summary class="text-sm font-semibold text-slate-400 cursor-pointer hover:text-white select-none mb-2">What-If Projection</summary>
896
+ <div class="bg-slate-900 rounded-lg border border-slate-700 p-4">
897
+ <div class="grid grid-cols-5 gap-4 mb-4">
898
+ <div>
899
+ <label class="block text-xs text-slate-500 mb-1">Avg Chunk Size (chars)</label>
900
+ <input type="range" id="proj-chunk-size" min="200" max="2000" step="100" value="600" class="w-full">
901
+ <div class="text-xs text-slate-400 text-center mt-1" id="proj-chunk-size-val">600</div>
902
+ </div>
903
+ <div>
904
+ <label class="block text-xs text-slate-500 mb-1">Chunks/Query</label>
905
+ <input type="range" id="proj-chunks-query" min="1" max="10" step="1" value="3" class="w-full">
906
+ <div class="text-xs text-slate-400 text-center mt-1" id="proj-chunks-query-val">3</div>
907
+ </div>
908
+ <div>
909
+ <label class="block text-xs text-slate-500 mb-1">Reads/Query (no RAG)</label>
910
+ <input type="range" id="proj-reads-without" min="0" max="10" step="0.5" value="2.5" class="w-full">
911
+ <div class="text-xs text-slate-400 text-center mt-1" id="proj-reads-without-val">2.5</div>
912
+ </div>
913
+ <div>
914
+ <label class="block text-xs text-slate-500 mb-1">Reads/Query (with RAG)</label>
915
+ <input type="range" id="proj-reads-with" min="0" max="10" step="0.5" value="0.5" class="w-full">
916
+ <div class="text-xs text-slate-400 text-center mt-1" id="proj-reads-with-val">0.5</div>
917
+ </div>
918
+ <div>
919
+ <label class="block text-xs text-slate-500 mb-1">Query Count</label>
920
+ <input type="range" id="proj-query-count" min="1" max="100" step="1" value="10" class="w-full">
921
+ <div class="text-xs text-slate-400 text-center mt-1" id="proj-query-count-val">10</div>
922
+ </div>
923
+ </div>
924
+ <div class="grid grid-cols-4 gap-3">
925
+ <div class="kpi-card p-3 text-center">
926
+ <div class="text-slate-500 text-xs mb-1">RAG Overhead</div>
927
+ <div class="text-lg font-bold text-yellow-400" id="proj-overhead">-</div>
928
+ </div>
929
+ <div class="kpi-card p-3 text-center">
930
+ <div class="text-slate-500 text-xs mb-1">Reads Saved</div>
931
+ <div class="text-lg font-bold text-green-400" id="proj-saved">-</div>
932
+ </div>
933
+ <div class="kpi-card p-3 text-center">
934
+ <div class="text-slate-500 text-xs mb-1">Net Savings</div>
935
+ <div class="text-lg font-bold" id="proj-net">-</div>
936
+ </div>
937
+ <div class="kpi-card p-3 text-center">
938
+ <div class="text-slate-500 text-xs mb-1">Verdict</div>
939
+ <div class="text-lg font-bold" id="proj-verdict">-</div>
940
+ </div>
941
+ </div>
942
+ </div>
943
+ </details>
944
+ </div>
945
+ </div>
755
946
  `;
756
947
 
757
948
  // Row click -> show detail
@@ -787,6 +978,7 @@
787
978
  // Compare button
788
979
  $('#eval-compare-btn')?.addEventListener('click', () => {
789
980
  const ids = [...selected];
981
+ console.log('[Compare] clicked, selected ids:', ids);
790
982
  if (ids.length === 2) renderEvalComparison(ids[0], ids[1]);
791
983
  });
792
984
 
@@ -809,6 +1001,57 @@
809
1001
  btn.textContent = `Compare Selected (${selected.size})`;
810
1002
  }
811
1003
  }
1004
+
1005
+ // What-If Projection sliders
1006
+ let projDebounce = null;
1007
+ const projSliders = [
1008
+ { id: 'proj-chunk-size', valId: 'proj-chunk-size-val' },
1009
+ { id: 'proj-chunks-query', valId: 'proj-chunks-query-val' },
1010
+ { id: 'proj-reads-without', valId: 'proj-reads-without-val' },
1011
+ { id: 'proj-reads-with', valId: 'proj-reads-with-val' },
1012
+ { id: 'proj-query-count', valId: 'proj-query-count-val' },
1013
+ ];
1014
+ projSliders.forEach(({ id, valId }) => {
1015
+ const slider = document.getElementById(id);
1016
+ const label = document.getElementById(valId);
1017
+ if (!slider || !label) return;
1018
+ slider.addEventListener('input', () => {
1019
+ label.textContent = slider.value;
1020
+ if (projDebounce) clearTimeout(projDebounce);
1021
+ projDebounce = setTimeout(updateProjection, 200);
1022
+ });
1023
+ });
1024
+ updateProjection();
1025
+ }
1026
+
1027
+ async function updateProjection() {
1028
+ const params = {
1029
+ avgChunkSize: Number(document.getElementById('proj-chunk-size')?.value ?? 600),
1030
+ avgChunksPerQuery: Number(document.getElementById('proj-chunks-query')?.value ?? 3),
1031
+ avgReadsPerQueryWithoutRAG: Number(document.getElementById('proj-reads-without')?.value ?? 2.5),
1032
+ avgReadsPerQueryWithRAG: Number(document.getElementById('proj-reads-with')?.value ?? 0.5),
1033
+ queryCount: Number(document.getElementById('proj-query-count')?.value ?? 10),
1034
+ };
1035
+
1036
+ const data = await API.evalProjectSavings(params);
1037
+ const p = data?.projection;
1038
+ if (!p) return;
1039
+
1040
+ const overheadEl = document.getElementById('proj-overhead');
1041
+ const savedEl = document.getElementById('proj-saved');
1042
+ const netEl = document.getElementById('proj-net');
1043
+ const verdictEl = document.getElementById('proj-verdict');
1044
+
1045
+ if (overheadEl) overheadEl.textContent = formatTokens(p.ragOverheadTokens);
1046
+ if (savedEl) savedEl.textContent = formatTokens(p.savedReadTokens);
1047
+ if (netEl) {
1048
+ netEl.textContent = (p.netSavings > 0 ? '+' : '') + formatTokens(p.netSavings);
1049
+ netEl.className = `text-lg font-bold ${p.netSavings > 0 ? 'text-green-400' : p.netSavings < 0 ? 'text-red-400' : 'text-slate-400'}`;
1050
+ }
1051
+ if (verdictEl) {
1052
+ verdictEl.textContent = p.isPositive ? 'SAVES' : 'COSTS';
1053
+ verdictEl.className = `text-lg font-bold ${p.isPositive ? 'text-green-400' : 'text-red-400'}`;
1054
+ }
812
1055
  }
813
1056
 
814
1057
  async function renderEvalDetail(sessionId) {
@@ -827,8 +1070,6 @@
827
1070
  const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
828
1071
  const totalAll = totalIn + s.totalTokens.output + s.totalTokens.reasoning;
829
1072
 
830
- const toolEntries = Object.entries(s.toolCallCounts).sort((a, b) => b[1] - a[1]);
831
-
832
1073
  area.innerHTML = `
833
1074
  <div class="mb-4">
834
1075
  <div class="flex items-center gap-3 mb-3">
@@ -859,6 +1100,8 @@
859
1100
  </div>
860
1101
  </div>
861
1102
 
1103
+ ${renderTokenDonut(s)}
1104
+
862
1105
  <div class="grid grid-cols-4 gap-3 mb-4">
863
1106
  <div class="kpi-card p-3">
864
1107
  <div class="text-slate-400 text-xs mb-1">Messages</div>
@@ -878,18 +1121,6 @@
878
1121
  </div>
879
1122
  </div>
880
1123
 
881
- ${toolEntries.length > 0 ? `
882
- <div class="kpi-card p-3 mb-4">
883
- <h4 class="text-xs font-semibold text-slate-400 mb-2">Tool Calls</h4>
884
- <div class="flex flex-wrap gap-2">
885
- ${toolEntries.map(([name, count]) => {
886
- const isRag = ['search_semantic','get_file_skeleton','find_usages','read'].includes(name);
887
- const cls = isRag ? 'bg-cyan-900/50 text-cyan-300 border-cyan-700' : 'bg-slate-800 text-slate-400 border-slate-700';
888
- return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs border ${cls}">${escapeHtml(name)} <strong>${count}</strong></span>`;
889
- }).join('')}
890
- </div>
891
- </div>` : ''}
892
-
893
1124
  ${s.models.length > 0 ? `
894
1125
  <div class="kpi-card p-3 mb-4">
895
1126
  <h4 class="text-xs font-semibold text-slate-400 mb-2">Models Used</h4>
@@ -899,6 +1130,8 @@
899
1130
  </div>` : ''}
900
1131
  </div>
901
1132
 
1133
+ <div id="token-analysis-area" class="mb-4"></div>
1134
+
902
1135
  <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
903
1136
  <div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
904
1137
  <span class="text-xs font-semibold text-slate-400">Event Timeline</span>
@@ -909,6 +1142,104 @@
909
1142
  </div>
910
1143
  </div>
911
1144
  `;
1145
+
1146
+ renderTokenAnalysis(sessionId);
1147
+ }
1148
+
1149
+ async function renderTokenAnalysis(sessionId) {
1150
+ const area = $('#token-analysis-area');
1151
+ if (!area) return;
1152
+
1153
+ const data = await API.evalAnalysis(sessionId);
1154
+ if (!data || data.error || !data.analysis) {
1155
+ area.innerHTML = '';
1156
+ return;
1157
+ }
1158
+
1159
+ const a = data.analysis;
1160
+ const e = a.estimates;
1161
+ const savingsColor = e.netSavings > 0 ? 'text-green-400' : e.netSavings < 0 ? 'text-red-400' : 'text-slate-400';
1162
+ const savingsSign = e.netSavings > 0 ? '+' : '';
1163
+
1164
+ area.innerHTML = `
1165
+ <div class="bg-slate-900 rounded-lg border border-slate-700 p-4 mb-4">
1166
+ <h4 class="text-sm font-semibold text-white mb-3">Token Analysis</h4>
1167
+
1168
+ <div class="grid grid-cols-3 gap-3 mb-4">
1169
+ <div class="kpi-card p-3">
1170
+ <div class="text-slate-400 text-xs mb-1">Est. Tokens Without RAG</div>
1171
+ <div class="text-xl font-bold text-white">${formatTokens(e.tokensWithoutRAG)}</div>
1172
+ </div>
1173
+ <div class="kpi-card p-3">
1174
+ <div class="text-slate-400 text-xs mb-1">Tokens With RAG (actual)</div>
1175
+ <div class="text-xl font-bold text-white">${formatTokens(e.tokensWithRAG)}</div>
1176
+ </div>
1177
+ <div class="kpi-card p-3">
1178
+ <div class="text-slate-400 text-xs mb-1">Net Savings</div>
1179
+ <div class="text-xl font-bold ${savingsColor}">${savingsSign}${formatTokens(e.netSavings)}</div>
1180
+ <div class="text-xs ${savingsColor}">${savingsSign}${e.percentSavings}%</div>
1181
+ </div>
1182
+ </div>
1183
+
1184
+ <div class="grid grid-cols-4 gap-3 mb-4 text-xs">
1185
+ <div class="text-slate-400">
1186
+ <span class="block text-slate-500">RAG Context</span>
1187
+ <span class="text-white font-semibold">${formatTokens(a.totals.ragContextTokens)}</span>
1188
+ </div>
1189
+ <div class="text-slate-400">
1190
+ <span class="block text-slate-500">System Guidance</span>
1191
+ <span class="text-white font-semibold">${formatTokens(a.totals.systemGuidanceTokens)}</span>
1192
+ </div>
1193
+ <div class="text-slate-400">
1194
+ <span class="block text-slate-500">Read Tool Calls</span>
1195
+ <span class="text-white font-semibold">${a.totals.readToolCalls}</span>
1196
+ </div>
1197
+ <div class="text-slate-400">
1198
+ <span class="block text-slate-500">RAG Tool Calls</span>
1199
+ <span class="text-cyan-400 font-semibold">${a.totals.ragToolCalls}</span>
1200
+ </div>
1201
+ </div>
1202
+
1203
+ ${a.breakdowns.length > 0 ? `
1204
+ <details class="group">
1205
+ <summary class="text-xs font-semibold text-slate-400 cursor-pointer hover:text-white select-none">Per-Query Breakdown (${a.breakdowns.length} queries)</summary>
1206
+ <div class="mt-2 overflow-x-auto">
1207
+ <table class="w-full text-xs">
1208
+ <thead>
1209
+ <tr class="text-slate-500 border-b border-slate-800">
1210
+ <th class="px-2 py-1 text-left">#</th>
1211
+ <th class="px-2 py-1 text-right">Input</th>
1212
+ <th class="px-2 py-1 text-right">Output</th>
1213
+ <th class="px-2 py-1 text-right">RAG Ctx</th>
1214
+ <th class="px-2 py-1 text-right">Chunks</th>
1215
+ <th class="px-2 py-1 text-right">Reads</th>
1216
+ <th class="px-2 py-1 text-right">RAG Tools</th>
1217
+ <th class="px-2 py-1 text-right">Score</th>
1218
+ <th class="px-2 py-1 text-right">Time</th>
1219
+ </tr>
1220
+ </thead>
1221
+ <tbody>
1222
+ ${a.breakdowns.map((b, i) => {
1223
+ const hasRag = b.ragContextTokens > 0;
1224
+ const borderCls = hasRag ? 'border-l-2 border-l-cyan-600' : '';
1225
+ return `<tr class="border-b border-slate-800/50 ${borderCls}">
1226
+ <td class="px-2 py-1 text-slate-500">${i + 1}</td>
1227
+ <td class="px-2 py-1 text-right text-slate-300">${formatTokens(b.inputTokens)}</td>
1228
+ <td class="px-2 py-1 text-right text-slate-300">${formatTokens(b.outputTokens)}</td>
1229
+ <td class="px-2 py-1 text-right ${hasRag ? 'text-cyan-400' : 'text-slate-500'}">${hasRag ? formatTokens(b.ragContextTokens) : '-'}</td>
1230
+ <td class="px-2 py-1 text-right text-slate-400">${hasRag ? b.ragChunkCount : '-'}</td>
1231
+ <td class="px-2 py-1 text-right text-slate-400">${b.readToolCalls}</td>
1232
+ <td class="px-2 py-1 text-right ${b.ragToolCalls > 0 ? 'text-cyan-400' : 'text-slate-500'}">${b.ragToolCalls || '-'}</td>
1233
+ <td class="px-2 py-1 text-right text-slate-400">${b.ragTopScore > 0 ? b.ragTopScore.toFixed(2) : '-'}</td>
1234
+ <td class="px-2 py-1 text-right text-slate-500">${b.responseTimeMs > 0 ? formatMs(b.responseTimeMs) : '-'}</td>
1235
+ </tr>`;
1236
+ }).join('')}
1237
+ </tbody>
1238
+ </table>
1239
+ </div>
1240
+ </details>` : ''}
1241
+ </div>
1242
+ `;
912
1243
  }
913
1244
 
914
1245
  function renderTimelineEvent(ev) {
@@ -986,13 +1317,24 @@
986
1317
 
987
1318
  area.innerHTML = '<div class="text-center py-4 text-slate-500">Loading comparison...</div>';
988
1319
 
989
- const data = await API.evalCompare(idA, idB);
990
- if (!data || data.error) {
991
- area.innerHTML = `<div class="text-center py-4 text-red-400">${escapeHtml(data?.error || 'Comparison failed')}</div>`;
1320
+ let basicData, tokenData;
1321
+ try {
1322
+ [basicData, tokenData] = await Promise.all([
1323
+ API.evalCompare(idA, idB),
1324
+ API.evalTokenCompare(idA, idB),
1325
+ ]);
1326
+ } catch (err) {
1327
+ area.innerHTML = `<div class="text-center py-4 text-red-400">Comparison failed: ${escapeHtml(String(err))}</div>`;
1328
+ return;
1329
+ }
1330
+
1331
+ if (!basicData || basicData.error) {
1332
+ area.innerHTML = `<div class="text-center py-4 text-red-400">${escapeHtml(basicData?.error || 'Comparison failed')}</div>`;
992
1333
  return;
993
1334
  }
994
1335
 
995
- const { sessionA: a, sessionB: b, delta } = data;
1336
+ try {
1337
+ const { sessionA: a, sessionB: b, delta } = basicData;
996
1338
  const aTotalIn = a.totalTokens.input + a.totalTokens.cacheRead;
997
1339
  const bTotalIn = b.totalTokens.input + b.totalTokens.cacheRead;
998
1340
  const aTotal = aTotalIn + a.totalTokens.output + a.totalTokens.reasoning;
@@ -1018,6 +1360,14 @@
1018
1360
  { label: 'Avg Response Time', a: a.avgResponseTimeMs || 0, b: b.avgResponseTimeMs || 0, fmt: formatMs, pct: true },
1019
1361
  ];
1020
1362
 
1363
+ // Add token analysis rows if available
1364
+ const tc = tokenData?.comparison;
1365
+ if (tc) {
1366
+ rows.push(
1367
+ { label: 'Read Tool Calls', a: a.ragToolCalls || 0, b: b.ragToolCalls || 0, fmt: String, pct: false },
1368
+ );
1369
+ }
1370
+
1021
1371
  area.innerHTML = `
1022
1372
  <div class="mt-6 mb-4">
1023
1373
  <div class="flex items-center gap-3 mb-3">
@@ -1025,6 +1375,18 @@
1025
1375
  <button id="eval-close-compare" class="text-xs text-slate-500 hover:text-white ml-auto">Close</button>
1026
1376
  </div>
1027
1377
 
1378
+ ${tc ? `
1379
+ <div class="p-3 rounded-lg mb-4 ${tc.delta.inputTokens < 0 ? 'bg-green-900/20 border border-green-800' : tc.delta.inputTokens > 0 ? 'bg-red-900/20 border border-red-800' : 'bg-slate-800 border border-slate-700'}">
1380
+ <div class="text-sm font-semibold ${tc.delta.inputTokens < 0 ? 'text-green-300' : tc.delta.inputTokens > 0 ? 'text-red-300' : 'text-slate-300'}">${escapeHtml(tc.verdict)}</div>
1381
+ ${tokenData?.ragOn?.estimates ? `
1382
+ <div class="text-xs text-slate-400 mt-1">
1383
+ Token savings estimate: ${tokenData.ragOn.estimates.netSavings > 0 ? '+' : ''}${formatTokens(tokenData.ragOn.estimates.netSavings)} (${tokenData.ragOn.estimates.percentSavings}%)
1384
+ for RAG-on session
1385
+ </div>` : ''}
1386
+ </div>` : ''}
1387
+
1388
+ ${renderComparisonBars(a, b)}
1389
+
1028
1390
  <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
1029
1391
  <table class="w-full text-sm">
1030
1392
  <thead>
@@ -1063,12 +1425,29 @@
1063
1425
  : `RAG reduced context by ${formatTokens(Math.abs(delta.ragContextTokens))} tokens. The LLM processed ${formatTokens(Math.abs(delta.inputTokens))} fewer input tokens.`}
1064
1426
  </div>
1065
1427
  </div>` : ''}
1428
+
1429
+ ${tokenData?.ragOn?.estimates ? `
1430
+ <div class="grid grid-cols-2 gap-3 mt-3">
1431
+ <div class="kpi-card p-3">
1432
+ <div class="text-slate-400 text-xs mb-1">RAG-On Session Savings</div>
1433
+ <div class="text-lg font-bold ${tokenData.ragOn.estimates.netSavings > 0 ? 'text-green-400' : 'text-red-400'}">${tokenData.ragOn.estimates.netSavings > 0 ? '+' : ''}${formatTokens(tokenData.ragOn.estimates.netSavings)}</div>
1434
+ <div class="text-xs text-slate-500">est. ${tokenData.ragOn.estimates.percentSavings}% ${tokenData.ragOn.estimates.netSavings > 0 ? 'saved' : 'overhead'}</div>
1435
+ </div>
1436
+ <div class="kpi-card p-3">
1437
+ <div class="text-slate-400 text-xs mb-1">RAG-Off Session Savings</div>
1438
+ <div class="text-lg font-bold ${tokenData.ragOff.estimates.netSavings > 0 ? 'text-green-400' : 'text-red-400'}">${tokenData.ragOff.estimates.netSavings > 0 ? '+' : ''}${formatTokens(tokenData.ragOff.estimates.netSavings)}</div>
1439
+ <div class="text-xs text-slate-500">est. ${tokenData.ragOff.estimates.percentSavings}% ${tokenData.ragOff.estimates.netSavings > 0 ? 'saved' : 'overhead'}</div>
1440
+ </div>
1441
+ </div>` : ''}
1066
1442
  </div>
1067
1443
  `;
1068
1444
 
1069
1445
  $('#eval-close-compare')?.addEventListener('click', () => {
1070
1446
  area.innerHTML = '';
1071
1447
  });
1448
+ } catch (err) {
1449
+ area.innerHTML = `<div class="text-center py-4 text-red-400">Comparison rendering failed: ${escapeHtml(String(err))}</div>`;
1450
+ }
1072
1451
  }
1073
1452
 
1074
1453
  // Init