opencode-rag-plugin 1.11.0 → 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.
@@ -7,6 +7,13 @@
7
7
  <link rel="stylesheet" href="/ui/app.css">
8
8
  <link rel="stylesheet" href="/ui/github-dark.css">
9
9
  <script src="/ui/highlight.min.js"></script>
10
+ <style>
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; }
16
+ </style>
10
17
  </head>
11
18
  <body class="h-screen flex flex-col overflow-hidden">
12
19
  <!-- Top Bar -->
@@ -16,7 +23,6 @@
16
23
  <button id="nav-dashboard" class="nav-btn active px-3 py-1 rounded text-sm font-medium transition">Dashboard</button>
17
24
  <button id="nav-chunks" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Chunks</button>
18
25
  <button id="nav-files" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Files</button>
19
- <button id="nav-compare" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Compare</button>
20
26
  <button id="nav-evaluate" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Evaluate</button>
21
27
  </nav>
22
28
  <div class="flex-1"></div>
@@ -49,8 +55,7 @@
49
55
  <div id="chunks-detail" class="w-1/2 overflow-y-auto scrollbar-thin pl-1"></div>
50
56
  </div>
51
57
  <div id="view-files" class="h-full overflow-y-auto scrollbar-thin"></div>
52
- <div id="view-compare" class="h-full overflow-y-auto scrollbar-thin"></div>
53
- <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>
54
59
  </main>
55
60
  </div>
56
61
 
@@ -77,6 +82,9 @@
77
82
  async evalSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id)); return r.json(); },
78
83
  async evalDeleteSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); return r.json(); },
79
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(); },
80
88
  };
81
89
 
82
90
  const state = {
@@ -86,7 +94,7 @@
86
94
  selectedChunkId: null,
87
95
  chunkOffset: 0,
88
96
  chunkLimit: 50,
89
- compareIds: new Set(),
97
+
90
98
  collapsedDirs: new Set(),
91
99
  stats: null,
92
100
  files: [],
@@ -139,6 +147,213 @@
139
147
  });
140
148
  }
141
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
+
142
357
  async function renderDashboard() {
143
358
  const [stats, files] = await Promise.all([API.stats(), API.files()]);
144
359
  state.stats = stats;
@@ -209,7 +424,6 @@
209
424
  <table class="w-full text-sm">
210
425
  <thead>
211
426
  <tr class="bg-slate-800 text-slate-400 text-xs">
212
- <th class="px-3 py-2 text-left w-8"></th>
213
427
  <th class="px-3 py-2 text-left">File</th>
214
428
  <th class="px-3 py-2 text-left w-20">Lang</th>
215
429
  <th class="px-3 py-2 text-left">Description</th>
@@ -218,11 +432,9 @@
218
432
  <tbody>
219
433
  ${data.chunks.map((c, i) => {
220
434
  const id = c.id || `chunk-${i}`;
221
- const checked = state.compareIds.has(id) ? 'checked' : '';
222
435
  const selected = state.selectedChunkId === id ? ' selected' : '';
223
436
  return `
224
437
  <tr class="chunk-row${selected} border-t border-slate-800 cursor-pointer" data-id="${id}" data-index="${i}">
225
- <td class="px-3 py-2"><input type="checkbox" class="compare-check rounded" data-id="${id}" ${checked}></td>
226
438
  <td class="px-3 py-2 text-yellow-400 font-mono text-xs">${truncate(c.filePath, 40)}:${c.startLine}-${c.endLine}</td>
227
439
  <td class="px-3 py-2">${langBadge(c.language)}</td>
228
440
  <td class="px-3 py-2 text-slate-400 text-xs">${truncate(c.description || '', 50)}</td>
@@ -241,7 +453,6 @@
241
453
  // Row click -> select and show detail
242
454
  $$('.chunk-row').forEach(row => {
243
455
  row.addEventListener('click', (e) => {
244
- if (e.target.type === 'checkbox') return;
245
456
  const id = row.dataset.id;
246
457
  const idx = parseInt(row.dataset.index, 10);
247
458
  if (!id) return;
@@ -252,14 +463,6 @@
252
463
  });
253
464
  });
254
465
 
255
- $$('.compare-check').forEach(cb => {
256
- cb.addEventListener('change', (e) => {
257
- const id = e.target.dataset.id;
258
- if (e.target.checked) state.compareIds.add(id);
259
- else state.compareIds.delete(id);
260
- });
261
- });
262
-
263
466
  const prevBtn = $('#prev-page');
264
467
  const nextBtn = $('#next-page');
265
468
  if (prevBtn) prevBtn.addEventListener('click', () => { state.chunkOffset = Math.max(0, state.chunkOffset - state.chunkLimit); renderChunks(); });
@@ -485,52 +688,7 @@
485
688
  });
486
689
  }
487
690
 
488
- async function renderCompare() {
489
- const el = $('#view-compare');
490
- const ids = [...state.compareIds];
491
691
 
492
- if (ids.length === 0) {
493
- el.innerHTML = `
494
- <div class="text-center py-20">
495
- <h2 class="text-lg font-semibold text-slate-400 mb-2">No chunks selected</h2>
496
- <p class="text-sm text-slate-500">Go to the Chunks view, select 2-3 chunks using checkboxes, then come back here.</p>
497
- </div>`;
498
- return;
499
- }
500
-
501
- if (ids.length > 3) {
502
- el.innerHTML = `
503
- <div class="text-center py-20">
504
- <h2 class="text-lg font-semibold text-slate-400 mb-2">Too many selected</h2>
505
- <p class="text-sm text-slate-500">Select at most 3 chunks to compare. Currently selected: ${ids.length}</p>
506
- </div>`;
507
- return;
508
- }
509
-
510
- const data = await API.compare(ids);
511
-
512
- el.innerHTML = `
513
- <div class="flex items-center gap-4 mb-4">
514
- <h2 class="text-lg font-semibold text-white">Compare</h2>
515
- <span class="text-sm text-slate-400">${data.chunks.length} chunks</span>
516
- <button id="clear-compare" class="text-xs text-slate-500 hover:text-white">Clear selection</button>
517
- </div>
518
- <div class="grid gap-4" style="grid-template-columns: repeat(${data.chunks.length}, 1fr)">
519
- ${data.chunks.map(c => `
520
- <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
521
- <div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
522
- <div class="text-xs text-yellow-400 font-mono">${c.filePath}:${c.startLine}-${c.endLine}</div>
523
- <div class="flex items-center gap-2 mt-1">${langBadge(c.language)}</div>
524
- </div>
525
- ${c.description ? `<div class="px-3 py-2 text-xs text-slate-400 border-b border-slate-800">${c.description}</div>` : ''}
526
- <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>
527
- </div>
528
- `).join('')}
529
- </div>`;
530
-
531
- el.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b));
532
- $('#clear-compare')?.addEventListener('click', () => { state.compareIds.clear(); renderCompare(); });
533
- }
534
692
 
535
693
  async function selectChunkFromSearch(id) {
536
694
  showView('chunks');
@@ -615,7 +773,7 @@
615
773
  showView('files');
616
774
  renderFiles();
617
775
  });
618
- $('#nav-compare')?.addEventListener('click', () => { showView('compare'); renderCompare(); });
776
+
619
777
  $('#nav-evaluate')?.addEventListener('click', () => { showView('evaluate'); renderEvaluate(); });
620
778
 
621
779
  // ── Evaluate View ──────────────────────────────────────────────────
@@ -660,13 +818,15 @@
660
818
 
661
819
  if (sessions.length === 0) {
662
820
  el.innerHTML = `
663
- <div class="text-center py-20">
664
- <div class="text-4xl mb-3">&#128202;</div>
665
- <h2 class="text-lg font-semibold text-slate-400 mb-2">No sessions recorded yet</h2>
666
- <p class="text-sm text-slate-500 max-w-md mx-auto">
667
- Sessions are logged automatically when the OpenCodeRAG plugin is active.
668
- Use OpenCode normally every conversation is captured here with token usage, timing, and RAG context data.
669
- </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>
670
830
  </div>`;
671
831
  return;
672
832
  }
@@ -674,16 +834,17 @@
674
834
  const selected = state.evalSelectedSessions;
675
835
 
676
836
  el.innerHTML = `
677
- <div class="flex items-center gap-4 mb-4">
678
- <h2 class="text-lg font-semibold text-white">Evaluate</h2>
679
- <span class="text-sm text-slate-400">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
680
- <div class="flex-1"></div>
681
- <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>
682
- <button id="eval-select-all" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Select All</button>
683
- <button id="eval-clear-sel" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Clear</button>
684
- </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>
685
846
 
686
- <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">
687
848
  <table class="w-full text-sm">
688
849
  <thead>
689
850
  <tr class="bg-slate-800 text-slate-400 text-xs">
@@ -722,10 +883,66 @@
722
883
  }).join('')}
723
884
  </tbody>
724
885
  </table>
886
+ </div>
725
887
  </div>
726
888
 
727
- <div id="eval-detail-area"></div>
728
- <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>
729
946
  `;
730
947
 
731
948
  // Row click -> show detail
@@ -761,6 +978,7 @@
761
978
  // Compare button
762
979
  $('#eval-compare-btn')?.addEventListener('click', () => {
763
980
  const ids = [...selected];
981
+ console.log('[Compare] clicked, selected ids:', ids);
764
982
  if (ids.length === 2) renderEvalComparison(ids[0], ids[1]);
765
983
  });
766
984
 
@@ -783,6 +1001,57 @@
783
1001
  btn.textContent = `Compare Selected (${selected.size})`;
784
1002
  }
785
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
+ }
786
1055
  }
787
1056
 
788
1057
  async function renderEvalDetail(sessionId) {
@@ -801,8 +1070,6 @@
801
1070
  const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
802
1071
  const totalAll = totalIn + s.totalTokens.output + s.totalTokens.reasoning;
803
1072
 
804
- const toolEntries = Object.entries(s.toolCallCounts).sort((a, b) => b[1] - a[1]);
805
-
806
1073
  area.innerHTML = `
807
1074
  <div class="mb-4">
808
1075
  <div class="flex items-center gap-3 mb-3">
@@ -833,6 +1100,8 @@
833
1100
  </div>
834
1101
  </div>
835
1102
 
1103
+ ${renderTokenDonut(s)}
1104
+
836
1105
  <div class="grid grid-cols-4 gap-3 mb-4">
837
1106
  <div class="kpi-card p-3">
838
1107
  <div class="text-slate-400 text-xs mb-1">Messages</div>
@@ -852,18 +1121,6 @@
852
1121
  </div>
853
1122
  </div>
854
1123
 
855
- ${toolEntries.length > 0 ? `
856
- <div class="kpi-card p-3 mb-4">
857
- <h4 class="text-xs font-semibold text-slate-400 mb-2">Tool Calls</h4>
858
- <div class="flex flex-wrap gap-2">
859
- ${toolEntries.map(([name, count]) => {
860
- const isRag = ['search_semantic','get_file_skeleton','find_usages','read'].includes(name);
861
- const cls = isRag ? 'bg-cyan-900/50 text-cyan-300 border-cyan-700' : 'bg-slate-800 text-slate-400 border-slate-700';
862
- return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs border ${cls}">${escapeHtml(name)} <strong>${count}</strong></span>`;
863
- }).join('')}
864
- </div>
865
- </div>` : ''}
866
-
867
1124
  ${s.models.length > 0 ? `
868
1125
  <div class="kpi-card p-3 mb-4">
869
1126
  <h4 class="text-xs font-semibold text-slate-400 mb-2">Models Used</h4>
@@ -873,6 +1130,8 @@
873
1130
  </div>` : ''}
874
1131
  </div>
875
1132
 
1133
+ <div id="token-analysis-area" class="mb-4"></div>
1134
+
876
1135
  <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
877
1136
  <div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
878
1137
  <span class="text-xs font-semibold text-slate-400">Event Timeline</span>
@@ -883,6 +1142,104 @@
883
1142
  </div>
884
1143
  </div>
885
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
+ `;
886
1243
  }
887
1244
 
888
1245
  function renderTimelineEvent(ev) {
@@ -960,13 +1317,24 @@
960
1317
 
961
1318
  area.innerHTML = '<div class="text-center py-4 text-slate-500">Loading comparison...</div>';
962
1319
 
963
- const data = await API.evalCompare(idA, idB);
964
- if (!data || data.error) {
965
- 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>`;
966
1328
  return;
967
1329
  }
968
1330
 
969
- const { sessionA: a, sessionB: b, delta } = data;
1331
+ if (!basicData || basicData.error) {
1332
+ area.innerHTML = `<div class="text-center py-4 text-red-400">${escapeHtml(basicData?.error || 'Comparison failed')}</div>`;
1333
+ return;
1334
+ }
1335
+
1336
+ try {
1337
+ const { sessionA: a, sessionB: b, delta } = basicData;
970
1338
  const aTotalIn = a.totalTokens.input + a.totalTokens.cacheRead;
971
1339
  const bTotalIn = b.totalTokens.input + b.totalTokens.cacheRead;
972
1340
  const aTotal = aTotalIn + a.totalTokens.output + a.totalTokens.reasoning;
@@ -992,6 +1360,14 @@
992
1360
  { label: 'Avg Response Time', a: a.avgResponseTimeMs || 0, b: b.avgResponseTimeMs || 0, fmt: formatMs, pct: true },
993
1361
  ];
994
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
+
995
1371
  area.innerHTML = `
996
1372
  <div class="mt-6 mb-4">
997
1373
  <div class="flex items-center gap-3 mb-3">
@@ -999,6 +1375,18 @@
999
1375
  <button id="eval-close-compare" class="text-xs text-slate-500 hover:text-white ml-auto">Close</button>
1000
1376
  </div>
1001
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
+
1002
1390
  <div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
1003
1391
  <table class="w-full text-sm">
1004
1392
  <thead>
@@ -1037,12 +1425,29 @@
1037
1425
  : `RAG reduced context by ${formatTokens(Math.abs(delta.ragContextTokens))} tokens. The LLM processed ${formatTokens(Math.abs(delta.inputTokens))} fewer input tokens.`}
1038
1426
  </div>
1039
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>` : ''}
1040
1442
  </div>
1041
1443
  `;
1042
1444
 
1043
1445
  $('#eval-close-compare')?.addEventListener('click', () => {
1044
1446
  area.innerHTML = '';
1045
1447
  });
1448
+ } catch (err) {
1449
+ area.innerHTML = `<div class="text-center py-4 text-red-400">Comparison rendering failed: ${escapeHtml(String(err))}</div>`;
1450
+ }
1046
1451
  }
1047
1452
 
1048
1453
  // Init