Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,11 @@ skill loads, filtered by machine or project. Missing usage is identified rather
than guessed. These are usage metrics, not a billing dashboard or a measurement
of task correctness; the chart below uses sample history.

The total and token breakdown show exact counts: uncached input + cache reads +
cache writes + output (including reasoning). Usage is counted across all recorded
Token counts use compact units such as **350k**, **1.2M**, and **2.4B** in the
overview, breakdown, and daily activity summary. Hover over the total, breakdown
rows, or daily bars for exact counts. The breakdown includes uncached input +
cache reads + cache writes + output (including reasoning); rounded labels may not
sum exactly to the displayed total. Usage is counted across all recorded
providers and models in the selected projects and machines, not just the currently
selected model. Anthropic, OpenAI, Codex, DeepSeek, and compatible/local endpoints
use their reported usage; missing counts are never estimated from text.
Expand Down
39 changes: 32 additions & 7 deletions src/ava/app/desktop/qml/AnalyticsPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,21 @@ Pane {

function number(value) { return Number(value || 0).toLocaleString(Qt.locale(), "f", 0); }
function compact(value) {
if (value >= 1000000) return (value / 1000000).toFixed(1) + "M";
if (value >= 1000) return (value / 1000).toFixed(1) + "k";
// Promote rounded boundaries too, so 999,999 reads 1M, not 1000k.
const units = [{size: 1e9, suffix: "B"}, {size: 1e6, suffix: "M"}, {size: 1e3, suffix: "k"}];
for (const unit of units) {
if (value >= unit.size * 0.99995) {
const rounded = Number((value / unit.size).toFixed(1));
return rounded.toLocaleString(Qt.locale(), "f", rounded % 1 ? 1 : 0) + unit.suffix;
}
}
return number(value);
}
function duration(value) {
const minutes = Math.floor((value || 0) / 60000);
return minutes >= 60 ? Math.floor(minutes / 60) + "h " + minutes % 60 + "m" : minutes ? minutes + "m" : value ? "< 1m" : "0m";
}
function valueText(value) { return metric === "active_ms" ? duration(value) : number(value); }
function valueText(value) { return metric === "active_ms" ? duration(value) : metric === "tokens" ? compact(value) : number(value); }
function tickText(value) {
if (metric !== "active_ms") return compact(value);
const seconds = Math.round(value / 1000);
Expand Down Expand Up @@ -253,7 +259,18 @@ Pane {
uniformCellWidths: true
columnSpacing: Theme.spaceMd
rowSpacing: Theme.spaceMd
Card { objectName: "analyticsTokens"; Layout.fillWidth: true; Layout.fillHeight: true; title: "Total tokens"; value: pane.report.reports ? pane.number(pane.report.totals.tokens) : "—"; detail: pane.number(pane.report.totals.responses) + " model requests" }
Card {
objectName: "analyticsTokens"
Layout.fillWidth: true
Layout.fillHeight: true
title: "Total tokens"
readonly property string exactValue: pane.report.reports ? pane.number(pane.report.totals.tokens) : "—"
value: pane.report.reports ? pane.compact(pane.report.totals.tokens) : "—"
detail: pane.number(pane.report.totals.responses) + " model requests"
Accessible.name: title + ": " + exactValue
HoverHandler { id: totalTokensHover }
NativeToolTip { visible: totalTokensHover.hovered && pane.report.reports > 0; text: pane.number(pane.report.totals.tokens) + " tokens" }
}
Card { objectName: "analyticsTime"; Layout.fillWidth: true; Layout.fillHeight: true; title: "Active time"; value: pane.report.reports ? pane.duration(pane.report.totals.active_ms) : "—"; detail: "Overlapping sessions counted once" }
Card { Layout.fillWidth: true; Layout.fillHeight: true; title: "Tool calls"; value: pane.report.reports ? pane.compact(pane.report.totals.tools) : "—"; detail: pane.number(pane.report.totals.tool_errors) + " returned errors" }
Card { Layout.fillWidth: true; Layout.fillHeight: true; title: "Skills loaded"; value: pane.report.reports ? pane.number(pane.report.totals.skills) : "—"; detail: "Recorded instruction loads" }
Expand Down Expand Up @@ -295,6 +312,7 @@ Pane {
}
Label {
Layout.fillWidth: true
objectName: "analyticsActivitySummary"
text: pane.report.reports ? pane.valueText(pane.report.totals[pane.metric]) + (pane.metric === "tokens" ? " tokens" : pane.metric === "active_ms" ? " of agent execution" : pane.metric === "tools" ? " completed calls" : " skill loads") + " in this period" : "Statistics are not available yet"
color: Theme.secondaryText
font.pixelSize: Theme.caption
Expand Down Expand Up @@ -337,7 +355,7 @@ Pane {
readonly property var segments: pane.metric === "tokens" ? [modelData.output, modelData.cache_write, modelData.cached_read, modelData.input] : [modelData[pane.metric]]
activeFocusOnTab: true
Accessible.role: Accessible.StaticText
Accessible.name: pane.dayLabel(modelData.date) + ": " + pane.valueText(modelData[pane.metric]) + " " + pane.metrics.find(row => row.id === pane.metric).name
Accessible.name: pane.dayLabel(modelData.date) + ": " + (pane.metric === "tokens" ? pane.number(modelData.tokens) : pane.valueText(modelData[pane.metric])) + " " + pane.metrics.find(row => row.id === pane.metric).name
Keys.onLeftPressed: { if (index > 0) barRepeater.itemAt(index - 1).forceActiveFocus(); }
Keys.onRightPressed: { if (index + 1 < barRepeater.count) barRepeater.itemAt(index + 1).forceActiveFocus(); }
Rectangle { anchors.fill: parent; color: Theme.selection; visible: hover.hovered || bar.activeFocus; radius: 3 }
Expand Down Expand Up @@ -409,7 +427,7 @@ Pane {
contentItem: ColumnLayout {
spacing: Theme.spaceLg
Label { text: "Token breakdown"; font.pixelSize: Theme.sectionTitle; font.weight: Font.DemiBold }
Label { text: "Exact reported counts · sum to total tokens"; color: Theme.secondaryText; font.pixelSize: Theme.caption; Layout.fillWidth: true; wrapMode: Text.WordWrap }
Label { text: "Rounded counts · hover for exact tokens"; color: Theme.secondaryText; font.pixelSize: Theme.caption; Layout.fillWidth: true; wrapMode: Text.WordWrap }
Row {
id: tokenStack
Layout.fillWidth: true
Expand Down Expand Up @@ -440,7 +458,14 @@ Pane {
spacing: Theme.spaceSm
Rectangle { implicitWidth: 8; implicitHeight: 8; radius: 2; color: pane.colors[token.index] }
Label { Layout.fillWidth: true; text: token.modelData.name; font.pixelSize: Theme.body }
Label { objectName: "analyticsTokenValue_" + token.modelData.key; text: pane.report.reports && !token.unreported ? pane.number(pane.report.totals[token.modelData.key]) : "—"; font.pixelSize: Theme.body; font.weight: Font.DemiBold }
Label {
objectName: "analyticsTokenValue_" + token.modelData.key
readonly property string exactValue: pane.report.reports && !token.unreported ? pane.number(pane.report.totals[token.modelData.key]) : "—"
text: pane.report.reports && !token.unreported ? pane.compact(pane.report.totals[token.modelData.key]) : "—"
Accessible.name: token.modelData.name + ": " + exactValue + " tokens"
font.pixelSize: Theme.body
font.weight: Font.DemiBold
}
Label { text: pane.report.reports && !token.unreported ? pane.percentage(pane.report.totals[token.modelData.key], pane.report.totals.tokens) : "—"; Layout.preferredWidth: 36; horizontalAlignment: Text.AlignRight; font.pixelSize: Theme.caption; color: Theme.secondaryText }
}
Label { text: token.unreported ? "Not reported by these providers" : token.modelData.key === "cache_write" && pane.report.totals.cache_write_reports > 0 ? "Reported on " + pane.number(pane.report.totals.cache_write_reports) + " of " + pane.number(pane.report.totals.responses) + " requests" : token.modelData.detail; Layout.fillWidth: true; wrapMode: Text.WordWrap; leftPadding: 16; color: Theme.secondaryText; font.pixelSize: Theme.captionSmall }
Expand Down
22 changes: 19 additions & 3 deletions tests/test_desktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6894,12 +6894,28 @@ def displayed_tokens(text):
return int(''.join(character for character in text if character.isdecimal()))

def assert_displayed_token_sum():
values = [find_item(window, 'analyticsTokenValue_' + key).property('text')
# Rounded labels need not sum exactly; their accessible exact values do.
values = [find_item(window, 'analyticsTokenValue_' + key).property('exactValue')
for key in ('input', 'cached_read', 'cache_write', 'output')]
assert sum(displayed_tokens(value) for value in values) == displayed_tokens(
find_item(window, 'analyticsTokens').property('value'))
find_item(window, 'analyticsTokens').property('exactValue'))

assert displayed_tokens(find_item(window, 'analyticsTokens').property('value')) == 349650
assert displayed_tokens(find_item(window, 'analyticsTokens').property('exactValue')) == 349650
assert find_item(window, 'analyticsTokens').property('value').endswith('k')
assert len(find_item(window, 'analyticsTokens').property('value')) <= 6
assert 'k tokens in this period' in find_item(window, 'analyticsActivitySummary').property('text')
for key in ('input', 'cached_read', 'cache_write', 'output'):
assert find_item(window, 'analyticsTokenValue_' + key).property('text').endswith('k')

from PySide6.QtQml import QQmlExpression, qmlContext

pane = find_item(window, 'analyticsPane')
for value, expected in [(0, '0'), (999, '999'), (1000, '1k'), (1200000, '1.2M'),
(2400000000, '2.4B'), (999999, '1M'), (999999999, '1B')]:
expression = QQmlExpression(qmlContext(pane), pane, f'compact({value})')
actual, _ = expression.evaluate()
assert not expression.hasError(), expression.error().toString()
assert actual.replace(',', '.') == expected
assert_displayed_token_sum()
save_screenshot(window, 'analytics-week')
assert find_item(window, 'pageHeader').property('title') == 'Session information'
Expand Down
Loading