diff --git a/apps/desktop-tauri/src-tauri/permissions/commands.toml b/apps/desktop-tauri/src-tauri/permissions/commands.toml index 1ad3c0420..e671d3eb8 100644 --- a/apps/desktop-tauri/src-tauri/permissions/commands.toml +++ b/apps/desktop-tauri/src-tauri/permissions/commands.toml @@ -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", diff --git a/apps/desktop-tauri/src-tauri/src/commands/chart.rs b/apps/desktop-tauri/src-tauri/src/commands/chart.rs index dc301d919..9ee1b274e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/chart.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/chart.rs @@ -541,6 +541,35 @@ fn load_local_api_value_totals(now: DateTime) -> Vec Vec { + 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, diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index e85534070..b3bd1a2d5 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -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, diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6a10ed63e..a4fb55578 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -17,6 +17,7 @@ import type { ProviderDetail, ProviderLocalUsageSummary, LocalApiValueProvider, + CursorModelActivity, ProviderSummary, ProviderUsageSnapshot, ProviderTokenAccountsBridge, @@ -269,6 +270,10 @@ export function getLocalApiValueTotals(): Promise { return invoke("get_local_api_value_totals"); } +export function getCursorModelActivity(): Promise { + return invoke("get_cursor_model_activity"); +} + // ── Token account bridge ───────────────────────────────────────────── export function getTokenAccountProviders(): Promise { diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index c5376120e..cc8de2a86 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -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); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx index 901cd4289..36d5380e0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.test.tsx @@ -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); @@ -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 () => { @@ -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( + 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( + 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(); + }); }); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx index 56db17284..8f9ae7829 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx @@ -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, @@ -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 ( +
+
+ Cursor activity by model · 30 days + {formatTokens(total)} edits +
+
    + {rows.map((row) => { + const share = total > 0 ? (row.contributions / total) * 100 : 0; + return ( +
  • +
    + + {cursorModelLabel(row.model)} + + {Math.round(share)}% +
    + +
    + {formatTokens(row.contributions)} edits · {formatTokens(row.requests)} requests +
    +
  • + ); + })} +
+

+ AI code tracked by Cursor Composer, grouped by model. This is activity, not tokens or + spend (Cursor does not log either locally). +

+
+ ); +} + function EffortBreakdown({ efforts }: { efforts: LocalEffortCost[] }) { const priced = efforts.reduce((sum, tier) => sum + (tier.cost ?? 0), 0); return ( @@ -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(null); const usageWindows = providerLocalUsageWindows(providerSnapshot); const usageWindowsKey = usageWindows .map((window) => `${window.id}:${window.startsAt}:${window.endsAt}`) @@ -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 || @@ -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 (
@@ -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 ( +
+ +
+ ); + } return (
History unavailable @@ -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 (
History starts here @@ -379,6 +463,7 @@ export function ChartsSection({ providerId, accountEmail, providerSnapshot, t }: )} + {hasCursorActivity && } {data.localUsage && (
+ + + + + + + + + + + + Ceiling + Stay below + the ceiling. + Track your Codex, Claude, Cursor, Gemini, and + Copilot limits from the Windows taskbar. + + USAGE AT A GLANCE + Codex · Weekly + 100% + + + Claude · Session (5h) + 17% + + + Claude · Weekly + 38% + + + Cursor · Plan + 48% + + + ceiling.win + Windows · local-first · free + \ No newline at end of file diff --git a/docs/images/ceiling-screenshot.png b/docs/images/ceiling-screenshot.png new file mode 100644 index 000000000..035a27ed6 Binary files /dev/null and b/docs/images/ceiling-screenshot.png differ diff --git a/docs/images/ceiling-x-ad-square.png b/docs/images/ceiling-x-ad-square.png new file mode 100644 index 000000000..3e3909eda Binary files /dev/null and b/docs/images/ceiling-x-ad-square.png differ diff --git a/docs/images/ceiling-x-ad.png b/docs/images/ceiling-x-ad.png new file mode 100644 index 000000000..fd3d7a4ea Binary files /dev/null and b/docs/images/ceiling-x-ad.png differ diff --git a/rust/src/cursor_activity.rs b/rust/src/cursor_activity.rs new file mode 100644 index 000000000..f4c0a4c21 --- /dev/null +++ b/rust/src/cursor_activity.rs @@ -0,0 +1,152 @@ +//! Local Cursor activity, read from Cursor's on-disk AI code-tracking database. +//! +//! Cursor records which model produced each accepted AI code block in +//! `~/.cursor/ai-tracking/ai-code-tracking.db` (table `ai_code_hashes`). This is +//! *activity*, not tokens or dollars — Cursor does not log token usage locally — +//! so callers must present it as "code contributions by model", never as spend. + +use rusqlite::{Connection, OpenFlags}; +use std::path::{Path, PathBuf}; + +const DAY_MS: i64 = 86_400_000; + +/// Per-model Cursor Composer activity over a window. +#[derive(Debug, Clone, PartialEq)] +pub struct CursorModelActivity { + /// Model id as Cursor recorded it (e.g. "grok-4.5", "claude-sonnet-5", + /// "default" for Auto model selection). + pub model: String, + /// Tracked AI code blocks attributed to this model. + pub contributions: u64, + /// Distinct Cursor requests that produced them. + pub requests: u64, +} + +/// Default location of Cursor's AI code-tracking database, when present. +fn cursor_tracking_db_path() -> Option { + let path = dirs::home_dir()? + .join(".cursor") + .join("ai-tracking") + .join("ai-code-tracking.db"); + path.exists().then_some(path) +} + +/// Cursor Composer activity by model over the last `window_days` (relative to +/// `now_ms`), most-active model first. +/// +/// Returns empty when the database is absent or unreadable (Cursor not +/// installed, an older schema, or the file briefly locked mid-write) rather +/// than erroring — this is a best-effort local read. +pub fn cursor_model_activity(now_ms: i64, window_days: i64) -> Vec { + let Some(db) = cursor_tracking_db_path() else { + return Vec::new(); + }; + let since_ms = now_ms - window_days.max(0) * DAY_MS; + read_cursor_model_activity(&db, since_ms).unwrap_or_default() +} + +fn read_cursor_model_activity( + db: &Path, + since_ms: i64, +) -> rusqlite::Result> { + let conn = Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + aggregate_model_activity(&conn, since_ms) +} + +/// Group `ai_code_hashes` by model within the window. Split out from the +/// file-open path so it can be tested against an in-memory database. +fn aggregate_model_activity( + conn: &Connection, + since_ms: i64, +) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT model, COUNT(*) AS contributions, COUNT(DISTINCT requestId) AS requests + FROM ai_code_hashes + WHERE model IS NOT NULL AND model <> '' AND timestamp >= ?1 + GROUP BY model + ORDER BY contributions DESC, model ASC", + )?; + let rows = stmt.query_map([since_ms], |row| { + Ok(CursorModelActivity { + model: row.get::<_, String>(0)?, + contributions: row.get::<_, i64>(1)?.max(0) as u64, + requests: row.get::<_, i64>(2)?.max(0) as u64, + }) + })?; + rows.collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn seed(rows: &[(&str, i64, &str)]) -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE ai_code_hashes ( + hash TEXT, source TEXT, fileExtension TEXT, fileName TEXT, + requestId TEXT, conversationId TEXT, timestamp INTEGER, + model TEXT, createdAt INTEGER + );", + ) + .unwrap(); + for (model, ts, req) in rows { + conn.execute( + "INSERT INTO ai_code_hashes (model, timestamp, requestId) VALUES (?1, ?2, ?3)", + rusqlite::params![model, ts, req], + ) + .unwrap(); + } + conn + } + + fn query(conn: &Connection, since_ms: i64) -> Vec { + aggregate_model_activity(conn, since_ms).unwrap() + } + + #[test] + fn aggregates_by_model_ranked_by_contributions() { + // grok: 3 blocks over 2 requests; claude: 1 block, 1 request. + let conn = seed(&[ + ("grok-4.5", 1_000, "r1"), + ("grok-4.5", 1_000, "r1"), + ("grok-4.5", 1_000, "r2"), + ("claude-sonnet-5", 1_000, "r3"), + ]); + let rows = query(&conn, 0); + assert_eq!( + rows, + vec![ + CursorModelActivity { + model: "grok-4.5".to_string(), + contributions: 3, + requests: 2, + }, + CursorModelActivity { + model: "claude-sonnet-5".to_string(), + contributions: 1, + requests: 1, + }, + ] + ); + } + + #[test] + fn excludes_rows_before_the_window_and_blank_models() { + let conn = seed(&[ + ("grok-4.5", 5_000, "r1"), // in window + ("grok-4.5", 100, "r0"), // before window -> excluded + ("", 5_000, "r2"), // blank model -> excluded + ]); + let rows = query(&conn, 1_000); + assert_eq!( + rows, + vec![CursorModelActivity { + model: "grok-4.5".to_string(), + contributions: 1, + requests: 1, + }] + ); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 87c1b1de2..fc2f39d9a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -8,6 +8,7 @@ pub mod browser; pub mod cli; pub mod core; pub mod cost_scanner; +pub mod cursor_activity; pub mod host; pub mod locale; pub mod logging;