diff --git a/src/data.js b/src/data.js
index 83b08af..1b6d03c 100644
--- a/src/data.js
+++ b/src/data.js
@@ -1300,6 +1300,8 @@ function getSessionReplay(sessionId, project) {
};
}
+const CONTEXT_WINDOW = 200_000; // Claude's max context window (tokens)
+
// ── Pricing per model (per token, April 2026) ─────────────
const MODEL_PRICING = {
@@ -1329,11 +1331,15 @@ function getModelPricing(model) {
function computeSessionCost(sessionId, project) {
const found = findSessionFile(sessionId, project);
- if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, model: '' };
+ if (!found) return { cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, contextPctSum: 0, contextTurnCount: 0, model: '' };
let totalCost = 0;
let totalInput = 0;
let totalOutput = 0;
+ let totalCacheRead = 0;
+ let totalCacheCreate = 0;
+ let contextPctSum = 0;
+ let contextTurnCount = 0;
let model = '';
try {
@@ -1353,12 +1359,21 @@ function computeSessionCost(sessionId, project) {
const cacheRead = u.cache_read_input_tokens || 0;
const out = u.output_tokens || 0;
- totalInput += inp + cacheCreate + cacheRead;
+ totalInput += inp;
totalOutput += out;
+ totalCacheRead += cacheRead;
+ totalCacheCreate += cacheCreate;
totalCost += inp * pricing.input
+ cacheCreate * pricing.cache_create
+ cacheRead * pricing.cache_read
+ out * pricing.output;
+
+ // Track per-turn context window usage (average, not peak)
+ const contextThisTurn = inp + cacheCreate + cacheRead;
+ if (contextThisTurn > 0) {
+ contextPctSum += (contextThisTurn / CONTEXT_WINDOW) * 100;
+ contextTurnCount++;
+ }
}
// Codex: estimate from file size (no token usage in session files)
} catch {}
@@ -1377,7 +1392,7 @@ function computeSessionCost(sessionId, project) {
} catch {}
}
- return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, model };
+ return { cost: totalCost, inputTokens: totalInput, outputTokens: totalOutput, cacheReadTokens: totalCacheRead, cacheCreateTokens: totalCacheCreate, contextPctSum, contextTurnCount, model };
}
// ── Cost analytics ────────────────────────────────────────
@@ -1386,20 +1401,59 @@ function getCostAnalytics(sessions) {
const byDay = {};
const byProject = {};
const byWeek = {};
+ const byAgent = {};
let totalCost = 0;
let totalTokens = 0;
+ let totalInputTokens = 0;
+ let totalOutputTokens = 0;
+ let totalCacheReadTokens = 0;
+ let totalCacheCreateTokens = 0;
+ let globalContextPctSum = 0;
+ let globalContextTurnCount = 0;
+ let firstDate = null;
+ let lastDate = null;
+ let sessionsWithData = 0;
+ const agentNoCostData = {};
+ for (const s of sessions) {
+ if (!byAgent[s.tool]) byAgent[s.tool] = { cost: 0, sessions: 0, tokens: 0, estimated: false };
+ }
const sessionCosts = [];
for (const s of sessions) {
const costData = computeSessionCost(s.id, s.project);
const cost = costData.cost;
- const tokens = costData.inputTokens + costData.outputTokens;
- if (cost === 0 && tokens === 0) continue;
+ const tokens = costData.inputTokens + costData.outputTokens + costData.cacheReadTokens + costData.cacheCreateTokens;
+ if (cost === 0 && tokens === 0) {
+ if (!agentNoCostData[s.tool]) agentNoCostData[s.tool] = 0;
+ agentNoCostData[s.tool]++;
+ continue;
+ }
+ sessionsWithData++;
totalCost += cost;
totalTokens += tokens;
-
- // By day
+ totalInputTokens += costData.inputTokens;
+ totalOutputTokens += costData.outputTokens;
+ totalCacheReadTokens += costData.cacheReadTokens;
+ totalCacheCreateTokens += costData.cacheCreateTokens;
+
+ // Per-agent breakdown
+ const agent = s.tool || 'unknown';
+ if (!byAgent[agent]) byAgent[agent] = { cost: 0, sessions: 0, tokens: 0, estimated: false };
+ byAgent[agent].cost += cost;
+ byAgent[agent].sessions++;
+ byAgent[agent].tokens += tokens;
+ if (agent === 'codex') byAgent[agent].estimated = true;
+
+ // Context % across all turns
+ globalContextPctSum += costData.contextPctSum;
+ globalContextTurnCount += costData.contextTurnCount;
+
+ // Date range
const day = s.date || 'unknown';
+ if (s.date) {
+ if (!firstDate || s.date < firstDate) firstDate = s.date;
+ if (!lastDate || s.date > lastDate) lastDate = s.date;
+ }
if (!byDay[day]) byDay[day] = { cost: 0, sessions: 0, tokens: 0 };
byDay[day].cost += cost;
byDay[day].sessions++;
@@ -1429,14 +1483,29 @@ function getCostAnalytics(sessions) {
// Sort top sessions by cost
sessionCosts.sort((a, b) => b.cost - a.cost);
+ const days = firstDate && lastDate
+ ? Math.max(1, Math.round((new Date(lastDate) - new Date(firstDate)) / 86400000) + 1)
+ : 1;
+
return {
totalCost,
totalTokens,
- totalSessions: sessions.length,
+ totalInputTokens,
+ totalOutputTokens,
+ totalCacheReadTokens,
+ totalCacheCreateTokens,
+ avgContextPct: globalContextTurnCount > 0 ? Math.round(globalContextPctSum / globalContextTurnCount) : 0,
+ dailyRate: totalCost / days,
+ firstDate,
+ lastDate,
+ days,
+ totalSessions: sessionsWithData,
byDay,
byWeek,
byProject,
topSessions: sessionCosts.slice(0, 10),
+ byAgent,
+ agentNoCostData,
};
}
diff --git a/src/frontend/app.js b/src/frontend/app.js
index 3519af5..eee3953 100644
--- a/src/frontend/app.js
+++ b/src/frontend/app.js
@@ -1277,10 +1277,13 @@ async function openDetail(s) {
var row = document.getElementById('detail-real-cost');
if (row) {
row.style.display = '';
+ var cacheStr = '';
+ if ((costData.cacheReadTokens || 0) + (costData.cacheCreateTokens || 0) > 0)
+ cacheStr = ' / ' + formatTokens((costData.cacheReadTokens||0) + (costData.cacheCreateTokens||0)) + ' cache';
row.querySelector('span:last-child').innerHTML =
'$' + costData.cost.toFixed(2) + '' +
' ' +
- formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' +
+ formatTokens(costData.inputTokens) + ' in / ' + formatTokens(costData.outputTokens) + ' out' + cacheStr +
(costData.model ? ' (' + costData.model + ')' : '') + '';
}
// Update estimated badge to show it was estimated
@@ -1826,17 +1829,61 @@ async function renderAnalytics(container) {
var html = '
';
html += '
Cost Analytics
';
- // Summary cards
+ // ── Summary cards ──────────────────────────────────────────
html += '
';
- html += '
~$' + data.totalCost.toFixed(2) + 'Total estimated cost
';
+ html += '
$' + data.totalCost.toFixed(2) + 'Total cost (API-equivalent)
';
html += '
' + formatTokens(data.totalTokens) + 'Total tokens
';
+ html += '
$' + (data.dailyRate || 0).toFixed(2) + 'Avg per day (' + (data.days || 1) + ' days)
';
html += '
' + data.totalSessions + 'Sessions
';
- html += '
~$' + (data.totalCost / Math.max(data.totalSessions, 1)).toFixed(2) + 'Avg per session
';
html += '
';
- // Cost by day chart (bar chart)
- var days = Object.keys(data.byDay).sort();
- var last30 = days.slice(-30);
+ // ── Data coverage note ────────────────────────────────────
+ if (data.byAgent || data.agentNoCostData) {
+ var coverageparts = [];
+ var byAgent = data.byAgent || {};
+ var noCost = data.agentNoCostData || {};
+ if (byAgent['claude'] && byAgent['claude'].sessions > 0)
+ coverageparts.push('
Claude Code \u2713');
+ if (byAgent['claude-ext'] && byAgent['claude-ext'].sessions > 0)
+ coverageparts.push('
Claude Extension \u2713');
+ if (byAgent['codex'] && byAgent['codex'].sessions > 0)
+ coverageparts.push('
Codex ~est.');
+ if (byAgent['opencode'] && byAgent['opencode'].sessions > 0)
+ coverageparts.push(byAgent['opencode'].estimated
+ ? '
OpenCode ~est.'
+ : '
OpenCode \u2713');
+ ['cursor', 'kiro'].forEach(function(a) {
+ if (noCost[a] > 0)
+ coverageparts.push('
' + a + ' \u2717 (no token data)');
+ });
+ if (noCost['opencode'] > 0 && !(byAgent['opencode'] && byAgent['opencode'].sessions > 0))
+ coverageparts.push('
opencode \u2717 (no token data)');
+ if (coverageparts.length > 0) {
+ html += '
Cost data: ' + coverageparts.join(' \u00b7 ') + '
';
+ }
+ }
+
+ // ── Token breakdown ────────────────────────────────────────
+ if (data.totalInputTokens !== undefined) {
+ var totalTok = data.totalInputTokens + data.totalOutputTokens + data.totalCacheReadTokens + data.totalCacheCreateTokens;
+ var pctOf = function(n) { return totalTok > 0 ? Math.round(n / totalTok * 100) : 0; };
+ html += '
';
+ html += '
Token Breakdown
';
+ html += '
';
+ html += '
' + formatTokens(data.totalInputTokens) + 'Input' + pctOf(data.totalInputTokens) + '%
';
+ html += '
' + formatTokens(data.totalOutputTokens) + 'Output' + pctOf(data.totalOutputTokens) + '%
';
+ html += '
' + formatTokens(data.totalCacheReadTokens) + 'Cache read' + pctOf(data.totalCacheReadTokens) + '%
';
+ html += '
' + formatTokens(data.totalCacheCreateTokens) + 'Cache write' + pctOf(data.totalCacheCreateTokens) + '%
';
+ if (data.avgContextPct > 0) {
+ html += '
' + data.avgContextPct + '%Avg context usedof 200K
';
+ }
+ html += '
';
+ html += '
';
+ }
+
+ // ── Daily cost chart ───────────────────────────────────────
+ var dayKeys = Object.keys(data.byDay).sort();
+ var last30 = dayKeys.slice(-30);
if (last30.length > 0) {
var maxCost = Math.max.apply(null, last30.map(function(d) { return data.byDay[d].cost; }));
html += '
Daily Cost (last 30 days)
';
@@ -1845,7 +1892,7 @@ async function renderAnalytics(container) {
var c = data.byDay[d];
var pct = maxCost > 0 ? (c.cost / maxCost * 100) : 0;
var label = d.slice(5); // MM-DD
- html += '
';
+ html += '
';
html += '
';
html += '
' + label + '
';
html += '
';
@@ -1853,7 +1900,7 @@ async function renderAnalytics(container) {
html += '
';
}
- // Cost by project (horizontal bars)
+ // ── Cost by project ────────────────────────────────────────
var projects = Object.entries(data.byProject).sort(function(a, b) { return b[1].cost - a[1].cost; });
var topProjects = projects.slice(0, 10);
if (topProjects.length > 0) {
@@ -1867,19 +1914,19 @@ async function renderAnalytics(container) {
html += '
';
html += '
' + escHtml(name) + '';
html += '
';
- html += '
~$' + info.cost.toFixed(2) + '';
+ html += '
$' + info.cost.toFixed(2) + '';
html += '
';
});
html += '
';
}
- // Top expensive sessions
+ // ── Top expensive sessions ─────────────────────────────────
if (data.topSessions && data.topSessions.length > 0) {
html += 'Most Expensive Sessions
';
html += '
';
data.topSessions.forEach(function(s) {
html += '
';
- html += '~$' + s.cost.toFixed(2) + '';
+ html += '$' + s.cost.toFixed(2) + '';
html += '' + escHtml(s.project) + '';
html += '' + (s.date || '') + '';
html += '' + s.id.slice(0, 8) + '';
@@ -1888,6 +1935,27 @@ async function renderAnalytics(container) {
html += '
';
}
+ // ── Cost by agent ──────────────────────────────────────────
+ var agentEntries = Object.entries(data.byAgent || {}).filter(function(e) { return e[1].sessions > 0; });
+ if (agentEntries.length > 1) {
+ agentEntries.sort(function(a, b) { return b[1].cost - a[1].cost; });
+ html += '
Cost by Agent
';
+ html += '
';
+ var maxAgentCost = agentEntries[0][1].cost || 1;
+ agentEntries.forEach(function(entry) {
+ var name = entry[0]; var info = entry[1];
+ var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0;
+ var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro' }[name] || name;
+ var estMark = info.estimated ? '
~est.' : '';
+ html += '
';
+ html += '
' + label + estMark + '';
+ html += '
';
+ html += '
$' + info.cost.toFixed(2) + ' (' + info.sessions + ' sess.)';
+ html += '
';
+ });
+ html += '
';
+ }
+
html += '
';
container.innerHTML = html;
} catch (e) {
diff --git a/src/frontend/styles.css b/src/frontend/styles.css
index 3758fcb..50a136c 100644
--- a/src/frontend/styles.css
+++ b/src/frontend/styles.css
@@ -2073,6 +2073,49 @@ body {
.top-session-date { color: var(--text-muted); font-size: 12px; }
.top-session-id { font-family: monospace; font-size: 11px; color: var(--text-muted); }
+/* ── Data coverage indicators ──────────────────────────────── */
+
+.analytics-coverage {
+ font-size: 12px;
+ color: var(--text-muted);
+ margin: -8px 0 16px;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.coverage-ok { color: var(--accent-green); }
+.coverage-est { color: var(--accent-orange, #f59e0b); }
+.coverage-none { color: var(--text-muted); opacity: 0.6; }
+
+/* ── Token breakdown grid ─────────────────────────────────── */
+
+.token-breakdown-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 10px;
+}
+
+.token-type-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.token-type-val { font-size: 18px; font-weight: 700; color: var(--text); }
+.token-type-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
+.token-type-pct { font-size: 12px; color: var(--text-muted); }
+
+.token-cache-read { border-color: rgba(96, 165, 250, 0.3); }
+.token-cache-create { border-color: rgba(251, 191, 36, 0.3); }
+.token-context { border-color: rgba(168, 85, 247, 0.3); }
+
/* ── Update banner ──────────────────────────────────────────── */
.update-banner {