The Gitea repo was initialised from an earlier snapshot, so main and the machine this runs on had drifted apart in 153 files before any UI work started. This commit brings the branch up to the local tree as it stood on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows shows up as its own reviewable diff instead of being mixed in with the pre-existing divergence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
425 lines
21 KiB
Python
425 lines
21 KiB
Python
"""Dashboard tab — token usage & cost overview.
|
||
|
||
Top: header (period filter + display-currency picker + refresh), then stat
|
||
cards (total, input, output, cache tokens, and cost per bucket). Unit prices
|
||
still come from Monitoring's model pricing table (same ``usage.*`` config keys
|
||
— both screens always agree); the currency picker itself lives HERE, beside
|
||
refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens,
|
||
average per prompt, busiest day/hour. Data comes from the local usage log (one
|
||
event per model turn, recorded by the providers — real server counts when
|
||
available, ~4 chars/token estimates otherwise).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, timedelta
|
||
from typing import Dict, List, Optional
|
||
|
||
from PySide6.QtCore import Qt, QTimer, Signal
|
||
from PySide6.QtWidgets import (
|
||
QComboBox, QGridLayout, QHBoxLayout, QLabel,
|
||
QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget,
|
||
)
|
||
|
||
from ..core import usage_tracker as ut
|
||
from ..core.worker import AgentWorker
|
||
from ..i18n import on_language_changed, tr
|
||
from ..state import AppContext
|
||
from ..theme import current_palette
|
||
from .icons import icon
|
||
from .spline_chart import SplineChart
|
||
from .widgets import BudgetCard as _BudgetCard
|
||
from .widgets import StatCard as _StatCard
|
||
from .widgets import fmt_tokens as _fmt_tokens
|
||
|
||
|
||
class DashboardTab(QWidget):
|
||
status_message = Signal(str)
|
||
|
||
_PERIODS = ("today", "week", "month", "all")
|
||
|
||
def __init__(self, ctx: AppContext):
|
||
super().__init__()
|
||
self.ctx = ctx
|
||
|
||
outer = QVBoxLayout(self)
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||
content = QWidget()
|
||
scroll.setWidget(content)
|
||
outer.addWidget(scroll)
|
||
root = QVBoxLayout(content)
|
||
|
||
# ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard —
|
||
# cards, chart and habits all follow the selected week/month) + refresh
|
||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||
head = QHBoxLayout()
|
||
self._title = QLabel()
|
||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||
self.chart_prev_btn = QPushButton()
|
||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||
self.chart_prev_btn.setFixedWidth(30)
|
||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||
self._chart_period_lbl = QLabel()
|
||
self._chart_period_lbl.setObjectName("hint")
|
||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||
self._chart_period_lbl.setMinimumWidth(170)
|
||
self.chart_next_btn = QPushButton()
|
||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||
self.chart_next_btn.setFixedWidth(30)
|
||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||
self.gran_combo = QComboBox()
|
||
for g in ("week", "month", "year"):
|
||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||
self.metric_combo = QComboBox()
|
||
for m in ("cost", "tokens"):
|
||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||
self.metric_combo.currentIndexChanged.connect(self._refresh_chart)
|
||
# Display-currency picker — moved here from Monitoring's Token Usage
|
||
# card, right beside refresh; both screens still share the same
|
||
# usage.currency config key, so changing it here updates everywhere.
|
||
self.currency_lbl = QLabel()
|
||
self.currency_lbl.setObjectName("hint")
|
||
self.currency_combo = QComboBox()
|
||
for cur in ut.SUPPORTED_CURRENCIES:
|
||
self.currency_combo.addItem(cur, cur)
|
||
idx = self.currency_combo.findData(
|
||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||
self.refresh_btn = QPushButton("")
|
||
self.refresh_btn.setIcon(icon("refresh"))
|
||
self.refresh_btn.setFixedWidth(34)
|
||
self.refresh_btn.clicked.connect(self.refresh)
|
||
head.addWidget(self._title, 1)
|
||
head.addWidget(self.chart_prev_btn)
|
||
head.addWidget(self._chart_period_lbl)
|
||
head.addWidget(self.chart_next_btn)
|
||
head.addWidget(self.gran_combo)
|
||
head.addWidget(self.metric_combo)
|
||
head.addWidget(self.currency_lbl)
|
||
head.addWidget(self.currency_combo)
|
||
head.addWidget(self.refresh_btn)
|
||
root.addLayout(head)
|
||
|
||
# ---- stat cards ---------------------------------------------------
|
||
# Single row, 5 equal-width cards (same layout as Monitoring Overview)
|
||
cards_grid = QGridLayout()
|
||
cards_grid.setSpacing(8)
|
||
self.card_total = _StatCard()
|
||
self.card_in = _StatCard()
|
||
self.card_out = _StatCard()
|
||
self.card_cache = _StatCard()
|
||
self.card_cost = _StatCard()
|
||
for i, card in enumerate((self.card_total, self.card_in, self.card_out,
|
||
self.card_cache, self.card_cost)):
|
||
cards_grid.addWidget(card, 0, i)
|
||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||
self.budget_card = _BudgetCard()
|
||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||
cards_grid.addWidget(self.budget_card, 0, 5)
|
||
# Equal stretch on every column — otherwise the grid sizes each column
|
||
# to its widest cell's natural content (Budget's longer "$X / $Y" value
|
||
# + entry row made its column ~25% wider than the plain stat cards).
|
||
for col in range(6):
|
||
cards_grid.setColumnStretch(col, 1)
|
||
root.addLayout(cards_grid)
|
||
|
||
# ---- token/cost within the selected period (spline): WEEK → 7 days
|
||
# (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines
|
||
# compare the previous week / month. ----
|
||
chart_head = QHBoxLayout()
|
||
self._chart_title = QLabel()
|
||
self._chart_title.setStyleSheet("font-weight:600;")
|
||
chart_head.addWidget(self._chart_title, 1)
|
||
root.addLayout(chart_head)
|
||
self.chart = SplineChart()
|
||
root.addWidget(self.chart)
|
||
|
||
# ---- habits summary -------------------------------------------------
|
||
self._habits_title = QLabel()
|
||
self._habits_title.setStyleSheet("font-weight:600;")
|
||
habits_head = QHBoxLayout()
|
||
self.ai_analyze_btn = QPushButton()
|
||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||
# Apply an AI-suggested cost-saving strategy (enable auto-compress + tune
|
||
# the compression threshold) — only after the user clicks to approve it.
|
||
self.apply_strategy_btn = QPushButton()
|
||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||
self.apply_strategy_btn.setVisible(False)
|
||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||
habits_head.addWidget(self._habits_title, 1)
|
||
habits_head.addWidget(self.apply_strategy_btn)
|
||
habits_head.addWidget(self.ai_analyze_btn)
|
||
root.addLayout(habits_head)
|
||
self.habits = QTextBrowser()
|
||
self.habits.setOpenExternalLinks(False)
|
||
self.habits.setMinimumHeight(160)
|
||
root.addWidget(self.habits, 1)
|
||
# AI recommendations panel (filled by the ✨ button).
|
||
self._ai_title = QLabel()
|
||
self._ai_title.setStyleSheet("font-weight:600;")
|
||
self._ai_title.setVisible(False)
|
||
root.addWidget(self._ai_title)
|
||
self.ai_advice = QTextBrowser()
|
||
self.ai_advice.setOpenExternalLinks(False)
|
||
self.ai_advice.setMinimumHeight(140)
|
||
self.ai_advice.setVisible(False)
|
||
root.addWidget(self.ai_advice, 1)
|
||
|
||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||
self._timer = QTimer(self)
|
||
self._timer.setInterval(30_000)
|
||
self._timer.timeout.connect(self.refresh)
|
||
self._timer.start()
|
||
|
||
on_language_changed(self._retranslate)
|
||
self.refresh()
|
||
|
||
# ---- helpers -----------------------------------------------------------
|
||
def _pricing(self) -> Dict:
|
||
from ..core import model_pricing as mp
|
||
mp.sync_to_usage(self.ctx.config) # cost/total comes straight from the price table
|
||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||
|
||
def _on_currency_changed(self, _idx: int) -> None:
|
||
cur = self.currency_combo.currentData()
|
||
if not cur:
|
||
return
|
||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||
self.ctx.save()
|
||
self.refresh()
|
||
|
||
def _retranslate(self) -> None:
|
||
self._title.setText(tr("dashboard.title"))
|
||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||
self.refresh()
|
||
|
||
def _apply_budget(self) -> None:
|
||
"""Persist the spin box's value as the new budget — starts a fresh
|
||
remaining-balance window (spend before now is no longer counted)."""
|
||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
|
||
self.ctx.save()
|
||
self._refresh_budget()
|
||
|
||
def _refresh_budget(self) -> None:
|
||
from ..core import model_pricing as mp
|
||
pricing = self._pricing()
|
||
status = ut.budget_status(self.ctx.config)
|
||
if status is None:
|
||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||
self.budget_card.budget_spin.setValue(0.0)
|
||
return
|
||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||
pricing.get("currency", "USD"), self.ctx.config)
|
||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||
pricing.get("currency", "USD"), self.ctx.config)
|
||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||
pct = int(round(status["pct_used"] * 100))
|
||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||
# keep the entry field showing the CURRENT budget (in display currency) —
|
||
# only when it doesn't already have unsaved focus/edits from the user.
|
||
if not self.budget_card.budget_spin.hasFocus():
|
||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||
|
||
def _period_range(self):
|
||
"""The SELECTED period as an inclusive (start, end) date range — drives
|
||
the whole dashboard (cards, chart, habits)."""
|
||
gran = self.gran_combo.currentData() or "week"
|
||
start, end = ut.period_bounds(gran, self._chart_offset)
|
||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||
|
||
def _on_gran_changed(self, *_a) -> None:
|
||
self._chart_offset = 0 # period size changed → back to current
|
||
self.refresh() # the filter drives the WHOLE dashboard
|
||
|
||
def _chart_prev(self) -> None:
|
||
self._chart_offset -= 1 # page one period into the past
|
||
self.refresh()
|
||
|
||
def _chart_next(self) -> None:
|
||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||
self.refresh()
|
||
|
||
@staticmethod
|
||
def _delta_txt(cur: float, prev: float) -> str:
|
||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||
if not prev:
|
||
return ""
|
||
pct = (cur - prev) / prev * 100
|
||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||
return f"{arrow}{abs(pct):.0f}%"
|
||
|
||
def _refresh_chart(self, *_a) -> None:
|
||
"""Break the SELECTED period into its parts: WEEK → 7 days (Mon–Sun) ·
|
||
MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines mark the previous
|
||
week's / month's average per point with the % change of the totals."""
|
||
if not hasattr(self, "chart"):
|
||
return
|
||
gran = self.gran_combo.currentData() or "week"
|
||
metric = self.metric_combo.currentData() or "cost"
|
||
events = ut.load_events() # all events; breakdown slices by period
|
||
pricing = self._pricing()
|
||
parts = ut.period_breakdown(events, gran, pricing, offset=self._chart_offset)
|
||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) → +1 for the value
|
||
pts = [(row[0], float(row[mi + 1])) for row in parts]
|
||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||
# chart's y-axis label box is narrow; format_cost's full precision (up
|
||
# to 4 decimals for USD) overflowed it, clipping/obscuring the amount.
|
||
fmt = _fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||
|
||
# One dashed comparison line that FOLLOWS the filter: the selected period
|
||
# vs the previous SAME-granularity one — "Last week" in week view,
|
||
# "Last month" in month view, "Last year" in year view. Drawn at the
|
||
# previous period's average per point so it sits on-scale; the label shows
|
||
# the % change of the period totals.
|
||
cur = ut.period_totals(events, gran, pricing, self._chart_offset)
|
||
prev = ut.period_totals(events, gran, pricing, self._chart_offset - 1)
|
||
ref_key = {"week": "dashboard.ref_last_week",
|
||
"month": "dashboard.ref_last_month",
|
||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||
n_points = max(1, len(parts))
|
||
refs = []
|
||
if prev[mi] > 0:
|
||
# Muted on purpose: the comparison line is a reference, not the
|
||
# series — it must not compete with the accent-coloured spline.
|
||
refs.append((prev[mi] / n_points,
|
||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||
current_palette().text_muted))
|
||
self.chart.set_reference_lines(refs)
|
||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
|
||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||
|
||
# ---- main refresh --------------------------------------------------------
|
||
def refresh(self) -> None:
|
||
start, end = self._period_range()
|
||
events = ut.load_events(start, end)
|
||
|
||
s = ut.summarize(events)
|
||
pricing = self._pricing()
|
||
costs = ut.cost_usd_events(events, pricing) # honors the per-model price table
|
||
total_cost = sum(costs.values())
|
||
|
||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||
if s["estimated_share"] > 0 else "")
|
||
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]),
|
||
tr("dashboard.card_turns", n=s["turns"]))
|
||
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]),
|
||
ut.format_cost(costs["in"], pricing))
|
||
self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]),
|
||
ut.format_cost(costs["out"], pricing))
|
||
self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]),
|
||
ut.format_cost(costs["cache"], pricing))
|
||
self.card_cost.set(tr("dashboard.card_cost"),
|
||
ut.format_cost(total_cost, pricing, digits=2), est_note)
|
||
|
||
# ---- habits -----------------------------------------------------------
|
||
lines: List[str] = []
|
||
if not events:
|
||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||
else:
|
||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||
lines.append("<ol>")
|
||
for label, tok in s["top_labels"]:
|
||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||
lines.append(f"<li>{label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||
lines.append("</ol>")
|
||
src_parts = ", ".join(
|
||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {_fmt_tokens(v)}"
|
||
for k, v in s["by_source"])
|
||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||
f"{_fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||
if s["busiest_day"]:
|
||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||
if s["busiest_hour"] is not None:
|
||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||
if s["estimated_share"] > 0:
|
||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||
self.habits.setHtml("".join(lines))
|
||
self._refresh_chart()
|
||
self._refresh_budget()
|
||
|
||
def _apply_saving_strategy(self) -> None:
|
||
"""Apply an AI-suggested cost-saving strategy AFTER the user approves:
|
||
turn on auto-compress and compress earlier (lower threshold) + compress
|
||
content before sending it to the agent — cutting tokens on every turn."""
|
||
from PySide6.QtWidgets import QMessageBox
|
||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||
return
|
||
cx = self.ctx.config.data.setdefault("context", {})
|
||
cx["auto_compact"] = True
|
||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||
cx["compress_before_send"] = True # digest context before each turn
|
||
self.ctx.save()
|
||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||
|
||
# ---- AI habits analysis ----------------------------------------------------
|
||
def _ai_analyze(self) -> None:
|
||
"""✨ Send the aggregated numbers (never raw prompt text) to the active
|
||
provider and show habit feedback + token-saving recommendations."""
|
||
if getattr(self, "_ai_worker", None) is not None:
|
||
return
|
||
start, end = self._period_range()
|
||
events = ut.load_events(start, end)
|
||
if not events:
|
||
self.status_message.emit(tr("dashboard.no_data"))
|
||
return
|
||
summary = ut.summarize(events)
|
||
self.ai_analyze_btn.setEnabled(False)
|
||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||
ctx = self.ctx
|
||
|
||
def job(worker: AgentWorker):
|
||
from ..i18n import get_language
|
||
|
||
prompt = ut.build_ai_analysis_prompt(summary, get_language())
|
||
provider = ctx.build_active_provider()
|
||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||
cancel=worker.stop_event)
|
||
return {"text": (reply.get("content") or "").strip()}
|
||
|
||
def done(result: dict) -> None:
|
||
self._ai_worker = None
|
||
self.ai_analyze_btn.setEnabled(True)
|
||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||
text = result.get("text") or ""
|
||
if text:
|
||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||
self._ai_title.setVisible(True)
|
||
self.ai_advice.setMarkdown(text)
|
||
self.ai_advice.setVisible(True)
|
||
self.apply_strategy_btn.setVisible(True) # offer to apply the saving strategy
|
||
|
||
def failed(err: str) -> None:
|
||
self._ai_worker = None
|
||
self.ai_analyze_btn.setEnabled(True)
|
||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||
self.status_message.emit(str(err))
|
||
|
||
w = AgentWorker(job)
|
||
w.finished_ok.connect(done)
|
||
w.failed.connect(failed)
|
||
self._ai_worker = w
|
||
w.start() |