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
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/permissions/commands.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ commands.allow = [
"get_provider_chart_data",
"get_provider_local_usage_summary",
"get_local_api_value_totals",
"get_cursor_model_activity",
"reorder_providers",
"set_provider_cookie_source",
"get_provider_cookie_source",
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,35 @@ fn load_local_api_value_totals(now: DateTime<Local>) -> Vec<LocalApiValueProvide
.collect()
}

/// One model's local Cursor activity. This is code-contribution activity from
/// Cursor's on-disk tracking, NOT tokens or dollars (Cursor logs neither).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CursorModelActivityRow {
pub model: String,
pub contributions: u64,
pub requests: u64,
}

#[tauri::command]
pub async fn get_cursor_model_activity() -> Vec<CursorModelActivityRow> {
tauri::async_runtime::spawn_blocking(|| {
codexbar::cursor_activity::cursor_model_activity(current_unix_ms(), 30)
.into_iter()
.map(|activity| CursorModelActivityRow {
model: activity.model,
contributions: activity.contributions,
requests: activity.requests,
})
.collect()
})
.await
.unwrap_or_else(|err| {
tracing::warn!("Cursor model-activity worker failed: {}", err);
Vec::new()
})
}

#[tauri::command]
pub async fn get_provider_local_usage_summary(
provider_id: String,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ fn main() {
commands::get_provider_chart_data,
commands::get_provider_local_usage_summary,
commands::get_local_api_value_totals,
commands::get_cursor_model_activity,
commands::reorder_providers,
commands::set_provider_cookie_source,
commands::get_provider_cookie_source,
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ProviderDetail,
ProviderLocalUsageSummary,
LocalApiValueProvider,
CursorModelActivity,
ProviderSummary,
ProviderUsageSnapshot,
ProviderTokenAccountsBridge,
Expand Down Expand Up @@ -269,6 +270,10 @@ export function getLocalApiValueTotals(): Promise<LocalApiValueProvider[]> {
return invoke<LocalApiValueProvider[]>("get_local_api_value_totals");
}

export function getCursorModelActivity(): Promise<CursorModelActivity[]> {
return invoke<CursorModelActivity[]>("get_cursor_model_activity");
}

// ── Token account bridge ─────────────────────────────────────────────

export function getTokenAccountProviders(): Promise<TokenAccountSupportBridge[]> {
Expand Down
76 changes: 76 additions & 0 deletions apps/desktop-tauri/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -7868,6 +7868,82 @@ body:has(.dashboard-shell) #root {
font-size: 9.5px;
line-height: 1.4;
}
.cursor-activity {
padding: 9px 10px;
border: 1px solid color-mix(in srgb, var(--ceiling-glass-border) 70%, transparent);
border-radius: 9px;
background: color-mix(in srgb, var(--text-primary) 2%, transparent);
}
.cursor-activity__header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: baseline;
margin-bottom: 9px;
}
.cursor-activity__title {
color: var(--text-secondary);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.01em;
}
.cursor-activity__total {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.cursor-activity__rows {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
.cursor-activity__row-top {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
}
.cursor-activity__name {
overflow: hidden;
color: var(--text-primary);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.cursor-activity__share {
color: var(--text-primary);
font-size: 11.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.cursor-activity__track {
margin: 4px 0 3px;
height: 6px;
border-radius: 3px;
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
overflow: hidden;
}
.cursor-activity__track span {
display: block;
height: 100%;
border-radius: 3px;
background: var(--provider-brand, #49a3b0);
}
.cursor-activity__detail {
color: var(--text-secondary);
font-size: 9.5px;
font-variant-numeric: tabular-nums;
}
.cursor-activity__note {
margin: 9px 0 0;
color: var(--text-secondary);
font-size: 9.5px;
line-height: 1.4;
}
.api-value-card {
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--ceiling-glass-border) 75%, transparent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const tauriMocks = vi.hoisted(() => ({
getProviderChartData: vi.fn(),
getSettingsSnapshot: vi.fn(),
getCursorModelActivity: vi.fn(),
}));

vi.mock("../../../../../lib/tauri", () => tauriMocks);
Expand Down Expand Up @@ -84,6 +85,7 @@ describe("ChartsSection local usage summary", () => {
vi.clearAllMocks();
tauriMocks.getSettingsSnapshot.mockResolvedValue({ enableAnimations: false });
tauriMocks.getProviderChartData.mockResolvedValue(enrichedData);
tauriMocks.getCursorModelActivity.mockResolvedValue([]);
});

it("shows comparable processed totals and the seven-day token mix", async () => {
Expand Down Expand Up @@ -152,4 +154,47 @@ describe("ChartsSection local usage summary", () => {
await waitFor(() => expect(getByText("4.9B")).toBeTruthy(), { timeout: 2_500 });
expect(tauriMocks.getProviderChartData).toHaveBeenCalledTimes(2);
});

it("shows a Cursor activity-by-model card with shares and Auto relabel", async () => {
tauriMocks.getProviderChartData.mockResolvedValue({
...enrichedData,
providerId: "cursor",
localUsage: null,
});
tauriMocks.getCursorModelActivity.mockResolvedValue([
{ model: "grok-4.5", contributions: 750, requests: 30 },
{ model: "claude-sonnet-5", contributions: 250, requests: 10 },
{ model: "default", contributions: 0, requests: 1 },
]);

const { getByLabelText } = render(
<ChartsSection providerId="cursor" accountEmail={null} t={(key) => key} />,
);

const card = await waitFor(() => getByLabelText("Cursor activity by model over 30 days"));
expect(card.textContent).toContain("grok-4.5");
expect(card.textContent).toContain("75%"); // 750 of 1000
expect(card.textContent).toContain("claude-sonnet-5");
expect(card.textContent).toContain("25%");
// "default" is relabeled to "Auto".
expect(card.textContent).toContain("Auto");
// Honest framing: activity, not spend.
expect(card.textContent).toMatch(/activity, not tokens or spend/i);
});

it("shows Cursor activity even when chart history fails to load", async () => {
tauriMocks.getProviderChartData.mockRejectedValue(new Error("no history"));
tauriMocks.getCursorModelActivity.mockResolvedValue([
{ model: "grok-4.5", contributions: 100, requests: 5 },
]);

const { getByLabelText, queryByText } = render(
<ChartsSection providerId="cursor" accountEmail={null} t={(key) => key} />,
);

const card = await waitFor(() => getByLabelText("Cursor activity by model over 30 days"));
expect(card.textContent).toContain("grok-4.5");
// The bare "History unavailable" error must not replace the card.
expect(queryByText("History unavailable")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { useEffect, useState } from "react";
import { getProviderChartData, getSettingsSnapshot } from "../../../../../lib/tauri";
import {
getCursorModelActivity,
getProviderChartData,
getSettingsSnapshot,
} from "../../../../../lib/tauri";
import {
providerLocalUsageWindows,
providerSupportsChartData,
} from "../../../../../lib/providerCharts";
import type {
CursorModelActivity,
LocalEffortCost,
LocalModelCost,
LocalTokenBreakdown,
Expand Down Expand Up @@ -138,6 +143,48 @@ function effortLabel(effort: string): string {
return EFFORT_LABELS[effort] ?? effort;
}

function cursorModelLabel(model: string): string {
// Cursor records automatic model selection as "default".
return model === "default" ? "Auto" : model;
}

function CursorActivity({ rows }: { rows: CursorModelActivity[] }) {
const total = rows.reduce((sum, row) => sum + row.contributions, 0);
return (
<div className="cursor-activity" aria-label="Cursor activity by model over 30 days">
<div className="cursor-activity__header">
<span className="cursor-activity__title">Cursor activity by model · 30 days</span>
<span className="cursor-activity__total">{formatTokens(total)} edits</span>
</div>
<ul className="cursor-activity__rows">
{rows.map((row) => {
const share = total > 0 ? (row.contributions / total) * 100 : 0;
return (
<li className="cursor-activity__row" key={row.model}>
<div className="cursor-activity__row-top">
<span className="cursor-activity__name" title={row.model}>
{cursorModelLabel(row.model)}
</span>
<span className="cursor-activity__share">{Math.round(share)}%</span>
</div>
<div className="cursor-activity__track" aria-hidden="true">
<span style={{ width: `${share}%` }} />
</div>
<div className="cursor-activity__detail">
{formatTokens(row.contributions)} edits · {formatTokens(row.requests)} requests
</div>
</li>
);
})}
</ul>
<p className="cursor-activity__note">
AI code tracked by Cursor Composer, grouped by model. This is activity, not tokens or
spend (Cursor does not log either locally).
</p>
</div>
);
}

function EffortBreakdown({ efforts }: { efforts: LocalEffortCost[] }) {
const priced = efforts.reduce((sum, tier) => sum + (tier.cost ?? 0), 0);
return (
Expand Down Expand Up @@ -177,6 +224,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }:
const [loading, setLoading] = useState(true);
const [enriching, setEnriching] = useState(false);
const [failed, setFailed] = useState(false);
const [cursorActivity, setCursorActivity] = useState<CursorModelActivity[] | null>(null);
const usageWindows = providerLocalUsageWindows(providerSnapshot);
const usageWindowsKey = usageWindows
.map((window) => `${window.id}:${window.startsAt}:${window.endsAt}`)
Expand Down Expand Up @@ -243,6 +291,26 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }:
};
}, [providerId]);

useEffect(() => {
let cancelled = false;
if (providerId.toLowerCase() !== "cursor") {
setCursorActivity(null);
return () => {
cancelled = true;
};
}
getCursorModelActivity()
.then((rows) => {
if (!cancelled) setCursorActivity(rows);
})
.catch(() => {
if (!cancelled) setCursorActivity([]);
});
return () => {
cancelled = true;
};
}, [providerId]);

useEffect(() => {
if (
!data ||
Expand Down Expand Up @@ -296,6 +364,13 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }:
};
}, [data, providerId, accountEmail, usageWindowsKey]);

// Cursor activity is independent of chart history and only belongs to the
// Cursor provider. Guard on the current provider so a stale fetch from a
// previous selection can't flash in another provider's view.
const isCursor = providerId.toLowerCase() === "cursor";
const cursorRows = isCursor ? cursorActivity ?? [] : [];
const hasCursorActivity = cursorRows.length > 0;

if (loading) {
return (
<section className="provider-detail-section provider-detail-charts provider-detail-charts--loading">
Expand All @@ -309,6 +384,15 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }:
}

if (!data || failed) {
// Chart history is unavailable, but Cursor's local activity may still be
// readable — show it rather than a bare error.
if (hasCursorActivity) {
return (
<section className="provider-detail-section provider-detail-charts">
<CursorActivity rows={cursorRows} />
</section>
);
}
return (
<section className="provider-detail-section provider-detail-charts charts-data-empty">
<strong>History unavailable</strong>
Expand All @@ -322,7 +406,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }:
const hasLimits = data.quotaHistory.length > 0;
const hasLocalSummary = data.localUsage !== null;

if (!hasCredits && !hasUsage && !hasLimits && !hasLocalSummary) {
if (!hasCredits && !hasUsage && !hasLimits && !hasLocalSummary && !hasCursorActivity) {
return (
<section className="provider-detail-section provider-detail-charts charts-data-empty">
<strong>History starts here</strong>
Expand Down Expand Up @@ -379,6 +463,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }:
</span>
</div>
)}
{hasCursorActivity && <CursorActivity rows={cursorRows} />}
{data.localUsage && (
<div
className="usage-periods"
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,15 @@ export interface LocalApiValueProvider {
thirtyDays: LocalApiValuePeriod;
}

export interface CursorModelActivity {
/** Model id from Cursor's tracking (e.g. "grok-4.5", "default" for Auto). */
model: string;
/** Tracked AI code blocks attributed to this model. */
contributions: number;
/** Distinct Cursor requests that produced them. */
requests: number;
}

export interface LocalUsageWindowRequest {
id: string;
label: string;
Expand Down
Loading
Loading