diff --git a/src/panel/html.ts b/src/panel/html.ts
index 85059d12..3a09831a 100644
--- a/src/panel/html.ts
+++ b/src/panel/html.ts
@@ -684,8 +684,15 @@ async function loadOverview() {
document.getElementById('period-info').textContent = stats.period || '';
if (stats.opusCost > 0) {
- const saved = stats.saved || (stats.opusCost - stats.totalCostUsd);
- const pct = stats.savedPct || ((1 - stats.totalCostUsd / stats.opusCost) * 100);
+ // tracker.ts now returns saved already clamped to >= 0 and opusCost
+ // already inclusive of media (so comparing to totalCostUsd is
+ // apples-to-apples). Older summaries — or the rare path where saved
+ // is undefined — get the same Math.max clamp here so the panel
+ // never shows a negative dollar amount.
+ const saved = Math.max(0, stats.saved != null ? stats.saved : (stats.opusCost - stats.totalCostUsd));
+ const pct = stats.savedPct != null
+ ? Math.max(0, stats.savedPct)
+ : (stats.opusCost > 0 ? Math.max(0, (saved / stats.opusCost) * 100) : 0);
document.getElementById('savings-hero').style.display = 'flex';
document.getElementById('savings-amount').textContent = usdBig(saved);
document.getElementById('savings-pct').textContent = pct.toFixed(0) + '%';
diff --git a/src/stats/tracker.ts b/src/stats/tracker.ts
index 145e8687..ff48f155 100644
--- a/src/stats/tracker.ts
+++ b/src/stats/tracker.ts
@@ -257,6 +257,10 @@ export function recordUsage(
export function getStatsSummary(): {
stats: Stats;
opusCost: number;
+ /** All chat / token-billed model spend (excludes image / video / music). */
+ chatOnlyCost: number;
+ /** Per-image / per-second / per-track media generation spend. */
+ mediaCost: number;
saved: number;
savedPct: number;
avgCostPerRequest: number;
@@ -264,12 +268,36 @@ export function getStatsSummary(): {
} {
const stats = loadStats();
- // Calculate what it would cost with the Opus-tier baseline
- const opusCost =
+ // Hypothetical "if you'd used Opus for everything" baseline. Opus is a
+ // chat model — it can't replace ImageGen / VideoGen / Music (per_image,
+ // per_second, per_track billing), so for those rows the Opus-equivalent
+ // cost IS just the actual cost (no alternative). For chat rows, the
+ // baseline is the same tokens repriced at Opus rates.
+ //
+ // Walk byModel: rows with zero tokens are media (recordUsage stores
+ // image/video calls with inputTokens=0 outputTokens=0). Those count
+ // towards both sides equally; chat rows count at actual price on the
+ // "actual" side and at Opus rates on the "baseline" side. Keeping them
+ // on both sides means the displayed totals match the user's real
+ // spend rather than an unfamiliar chat-only subset.
+ let chatOnlyCost = 0;
+ let mediaCost = 0;
+ for (const m of Object.values(stats.byModel)) {
+ if ((m.inputTokens + m.outputTokens) > 0) chatOnlyCost += m.costUsd;
+ else mediaCost += m.costUsd;
+ }
+ const opusChatCost =
(stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input +
(stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output;
-
- const saved = opusCost - stats.totalCostUsd;
+ // Display-side baseline: include media on both sides so "you spent X
+ // instead of Y" shows real, comparable totals.
+ const opusCost = opusChatCost + mediaCost;
+
+ // Saved is the chat-side delta only — media nets to zero. Clamp to 0
+ // so a session where the user paid more than Opus-equivalent for chat
+ // (e.g. Sonnet 4.6 with extended thinking enabled) doesn't show a
+ // negative "savings" number; we just say zero saved.
+ const saved = Math.max(0, opusChatCost - chatOnlyCost);
const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0;
const avgCostPerRequest =
stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0;
@@ -285,5 +313,5 @@ export function getStatsSummary(): {
else period = `${days} days`;
}
- return { stats, opusCost, saved, savedPct, avgCostPerRequest, period };
+ return { stats, opusCost, chatOnlyCost, mediaCost, saved, savedPct, avgCostPerRequest, period };
}