Cursor.com Usage Tracker (Enhanced)

Tracks and displays usage statistics and detailed model costs for Premium models on Cursor.com.

当前为 2025-05-27 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Cursor.com Usage Tracker (Enhanced)
  3. // @author monnef, Sonnet 3.5 (via Perplexity and Cursor), some help from Cursor Tab and Cursor Small, NoahBPeterson, Sonnet 3.7, Gemini
  4. // @namespace http://monnef.eu
  5. // @version 0.5.3
  6. // @description Tracks and displays usage statistics and detailed model costs for Premium models on Cursor.com.
  7. // @match https://www.cursor.com/settings
  8. // @grant none
  9. // @require https://code.jquery.com/jquery-3.6.0.min.js
  10. // @license AGPL-3.0
  11. // @icon https://www.cursor.com/favicon-48x48.png
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. 'use strict';
  16.  
  17. const $ = jQuery.noConflict();
  18.  
  19. const $c = (cls, parent) => $(`.${cls}`, parent);
  20.  
  21. $.fn.nthParent = function (n) {
  22. return this.parents().eq(n - 1);
  23. };
  24.  
  25. const log = (...messages) => {
  26. console.log(`[UsageTracker]`, ...messages);
  27. };
  28.  
  29. const error = (...messages) => {
  30. console.error(`[UsageTracker]`, ...messages);
  31. };
  32.  
  33. const debug = (...messages) => {
  34. console.debug(`[UsageTracker Debug]`, ...messages);
  35. };
  36.  
  37. const genCssId = name => `ut-${name}`;
  38.  
  39. // --- CSS Class Names ---
  40. const mainCaptionCls = genCssId('main-caption');
  41. const hrCls = genCssId('hr');
  42. const multiBarCls = genCssId('multi-bar');
  43. const barSegmentCls = genCssId('bar-segment');
  44. const tooltipCls = genCssId('tooltip');
  45. const statsContainerCls = genCssId('stats-container');
  46. const statItemCls = genCssId('stat-item');
  47. const enhancedTrackerContainerCls = genCssId('enhanced-tracker'); // Specific class for the container
  48.  
  49. const colors = {
  50. cursor: {
  51. lightGray: '#e5e7eb',
  52. gray: '#a7a9ac',
  53. grayDark: '#333333',
  54. },
  55. segments: [ // A palette for the multi-segment bar
  56. '#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
  57. '#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
  58. '#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
  59. '#FF5722', '#795548', '#9E9E9E', '#607D8B'
  60. ]
  61. };
  62.  
  63. const styles = `
  64. .${hrCls} { border: 0; height: 1px; background-color: #333333; margin: 15px 0; }
  65. .${statsContainerCls} { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 20px; margin: 15px 0; padding: 15px; background-color: #1a1a1a; border-radius: 8px; }
  66. .${statItemCls} { font-size: 14px; }
  67. .${statItemCls} .label { color: ${colors.cursor.gray}; }
  68. .${statItemCls} .value { color: white; font-weight: bold; }
  69. .${multiBarCls} { display: flex; width: 100%; height: 15px; background-color: ${colors.cursor.grayDark}; border-radius: 4px; margin: 10px 0; }
  70. .${barSegmentCls} { height: 100%; position: relative; transition: filter 0.2s ease-in-out; }
  71. .${barSegmentCls}:hover { filter: brightness(1.2); }
  72. .${barSegmentCls} .${tooltipCls} {
  73. visibility: hidden;
  74. width: max-content;
  75. background-color: black;
  76. color: #fff;
  77. text-align: center;
  78. border-radius: 6px;
  79. padding: 5px 10px;
  80. position: absolute;
  81. z-index: 50;
  82. bottom: 150%;
  83. left: 50%;
  84. transform: translateX(-50%);
  85. opacity: 0;
  86. transition: opacity 0.3s;
  87. border: 1px solid ${colors.cursor.gray};
  88. font-size: 12px;
  89. pointer-events: none;
  90. }
  91. .${barSegmentCls}:hover .${tooltipCls} {
  92. visibility: visible;
  93. opacity: 1;
  94. }
  95. .${barSegmentCls} .${tooltipCls}::after {
  96. content: "";
  97. position: absolute;
  98. top: 100%;
  99. left: 50%;
  100. margin-left: -5px;
  101. border-width: 5px;
  102. border-style: solid;
  103. border-color: black transparent transparent transparent;
  104. }
  105. `;
  106.  
  107. const genHr = () => $('<hr>').addClass(hrCls);
  108.  
  109. // --- Data Parsing Functions ---
  110.  
  111. const parseUsageEventsTable = () => {
  112. const modelUsage = {};
  113. let totalPaidRequests = 0;
  114. let totalRequests = 0;
  115. let erroredRequests = 0;
  116.  
  117. const table = $('p:contains("Recent Usage Events")').closest('div').find('table tbody tr');
  118. // debug(`Parsing Usage Events Table: Found ${table.length} rows.`);
  119.  
  120. if (table.length === 0) {
  121. error("Recent Usage Events table: Not found or empty.");
  122. return { modelUsage, totalPaidRequests, totalRequests };
  123. }
  124.  
  125. table.each((_, row) => {
  126. const $row = $(row);
  127. const model = $row.find('td:eq(1)').text().trim();
  128. const status = $row.find('td:eq(2)').text().trim();
  129. const requestsStr = $row.find('td:eq(3)').text().trim();
  130. const requests = parseFloat(requestsStr) || 0;
  131.  
  132. if (status !== 'Errored, Not Charged' && model) {
  133. totalRequests += requests;
  134. if (!modelUsage[model]) {
  135. modelUsage[model] = { count: 0, cost: 0 };
  136. }
  137. modelUsage[model].count += requests;
  138.  
  139. if (status === 'Usage-based') {
  140. totalPaidRequests += requests;
  141. }
  142. } else if (status === 'Errored, Not Charged') {
  143. erroredRequests += 1;
  144. }
  145. });
  146. // debug('Finished Parsing Usage Events:', { modelUsage, totalPaidRequests, totalRequests });
  147. return { modelUsage, totalPaidRequests, totalRequests, erroredRequests };
  148. };
  149.  
  150. const parseCurrentUsageCosts = (modelUsage) => {
  151. let totalCost = 0;
  152. const costTable = $('p:contains("Current Usage"):last').closest('div').find('table tbody tr');
  153. // debug(`Parsing Current Usage Costs Table: Found ${costTable.length} rows.`);
  154.  
  155. if (costTable.length === 0) {
  156. error("Current Usage (Cost) table: Not found or empty.");
  157. return { totalCost };
  158. }
  159.  
  160. costTable.each((_, row) => {
  161. const $row = $(row);
  162. const description = $row.find('td:eq(0)').text().trim().toLowerCase();
  163. const costStr = $row.find('td:eq(1)').text().trim().replace('$', '');
  164. const cost = parseFloat(costStr) || 0;
  165. totalCost += cost;
  166.  
  167. let foundModel = false;
  168. for (const modelKey in modelUsage) { // Use modelKey to avoid conflict with 'model' variable
  169. if (description.includes(modelKey.toLowerCase())) {
  170. modelUsage[modelKey].cost += cost;
  171. foundModel = true;
  172. }
  173. }
  174. if (!foundModel && (description.includes('extra') || description.includes('premium')) && cost > 0 ) {
  175. if (!modelUsage['Extra/Other Premium']) {
  176. modelUsage['Extra/Other Premium'] = { count: 0, cost: 0 };
  177. }
  178. modelUsage['Extra/Other Premium'].cost += cost;
  179. foundModel = true; // Count this as found for "Other Costs" logic
  180. }
  181. if (!foundModel && cost > 0 && !description.includes('mid-month usage paid')) { // Avoid double-adding "Other Costs" for positive values if it was already an "Extra/Other Premium"
  182. if (!modelUsage['Other Costs']) {
  183. modelUsage['Other Costs'] = { count: 0, cost: 0 };
  184. }
  185. modelUsage['Other Costs'].cost += cost;
  186. }
  187. });
  188.  
  189. for (const modelKey in modelUsage) { // Use modelKey
  190. modelUsage[modelKey].cost = modelUsage[modelKey].cost || 0;
  191. }
  192.  
  193. // debug('Finished Parsing Costs & Updated Model Usage:', { modelUsage, totalCost });
  194. return { totalCost };
  195. };
  196.  
  197. const getBaseUsageData = () => {
  198. // debug('Attempting to find base usage data...');
  199. const premiumLabel = $('span:contains("Premium models")').first();
  200. if (premiumLabel.length === 0) {
  201. // debug('Base Premium models label not found.');
  202. return {};
  203. }
  204. const usageSpan = premiumLabel.siblings('span').last();
  205. const usageText = usageSpan.text();
  206. // debug(`Found base usage text: "${usageText}"`);
  207.  
  208. const regex = /(\d+) \/ (\d+)/;
  209. const matches = usageText.match(regex);
  210. if (matches && matches.length === 3) {
  211. const used = parseInt(matches[1], 10);
  212. const total = parseInt(matches[2], 10);
  213. // debug(`Parsed base values - Used: ${used}, Total: ${total}`);
  214. return { used, total };
  215. } else {
  216. // debug('Regex did not match the base usage text.');
  217. return {};
  218. }
  219. };
  220.  
  221. // --- Display Functions ---
  222.  
  223. const createMultiSegmentProgressBar = (modelUsage) => {
  224. const barContainer = $('<div>').addClass(multiBarCls);
  225. const totalRequests = Object.values(modelUsage).reduce((sum, model) => sum + (model.count || 0), 0);
  226.  
  227. if (totalRequests === 0) {
  228. return barContainer.text('No usage data for bar.').css({ height: 'auto', padding: '5px' });
  229. }
  230.  
  231. let colorIndex = 0;
  232. const sortedModels = Object.entries(modelUsage)
  233. .filter(([_, data]) => data.count > 0)
  234. .sort(([, a], [, b]) => b.count - a.count);
  235.  
  236. for (const [model, data] of sortedModels) {
  237. const percentage = (data.count / totalRequests) * 100;
  238. const cost = data.cost.toFixed(2);
  239. const color = colors.segments[colorIndex % colors.segments.length];
  240. const tooltipText = `${model}: ${data.count.toFixed(1)} reqs ($${cost})`;
  241. const tooltip = $('<span>').addClass(tooltipCls).text(tooltipText);
  242. const segment = $('<div>')
  243. .addClass(barSegmentCls)
  244. .css({ width: `${percentage}%`, backgroundColor: color })
  245. .append(tooltip);
  246. barContainer.append(segment);
  247. colorIndex++;
  248. }
  249. return barContainer;
  250. };
  251.  
  252. const displayEnhancedTrackerData = () => {
  253. debug('displayEnhancedTrackerData: Function START');
  254. const tracker = $c(mainCaptionCls);
  255. if (tracker.length === 0) {
  256. error('displayEnhancedTrackerData: Main caption element NOT FOUND. Cannot display stats.');
  257. return false;
  258. }
  259. debug(`displayEnhancedTrackerData: Found tracker caption element (length: ${tracker.length}). Class: ${mainCaptionCls}`);
  260.  
  261. // Clear previous tracker data
  262. const existingTrackerData = tracker.siblings(`.${enhancedTrackerContainerCls}`);
  263. debug(`displayEnhancedTrackerData: Found ${existingTrackerData.length} existing tracker data containers to remove.`);
  264. existingTrackerData.remove();
  265.  
  266. debug('displayEnhancedTrackerData: Calling parsing functions...');
  267. const { modelUsage, totalPaidRequests, totalRequests, erroredRequests } = parseUsageEventsTable();
  268. parseCurrentUsageCosts(modelUsage);
  269. const baseUsage = getBaseUsageData();
  270. debug('displayEnhancedTrackerData: Parsing functions COMPLETE.');
  271.  
  272. const container = $('<div>').addClass(enhancedTrackerContainerCls);
  273. const statsContainer = $('<div>').addClass(statsContainerCls);
  274.  
  275. const addStat = (label, value) => {
  276. statsContainer.append(
  277. $('<div>').addClass(statItemCls).append(
  278. $('<span>').addClass('label').text(`${label}: `),
  279. $('<span>').addClass('value').text(value)
  280. )
  281. );
  282. }
  283.  
  284. addStat('Weighted Usage-Based Requests', totalPaidRequests.toFixed(1));
  285. addStat('Total Requests', (totalRequests+baseUsage.used).toFixed(1));
  286. addStat('Errored Requests', (erroredRequests).toFixed(1));
  287.  
  288. container.append(
  289. statsContainer,
  290. $('<p>').css({ fontSize: '13px', color: colors.cursor.gray, marginTop: '10px' }).text('Model Usage Breakdown (Weighted, Hover for details):'),
  291. createMultiSegmentProgressBar(modelUsage)
  292. );
  293.  
  294. debug('displayEnhancedTrackerData: Stats container PREPARED. Appending to DOM...');
  295. tracker.after(container);
  296. debug('displayEnhancedTrackerData: Enhanced tracker data supposedly displayed.');
  297. return true;
  298. };
  299.  
  300. // --- Core Script Functions ---
  301.  
  302. const decorateUsageCard = () => {
  303. debug("decorateUsageCard: Function START");
  304. if ($c(mainCaptionCls).length > 0) {
  305. debug("decorateUsageCard: Card already decorated.");
  306. return true;
  307. }
  308. const usageHeading = $('h2:contains("Usage")');
  309. if (usageHeading.length > 0) {
  310. debug(`decorateUsageCard: Found 'h2:contains("Usage")' (count: ${usageHeading.length}). Decorating...`);
  311. const caption = $('<div>')
  312. .addClass('font-medium gt-standard-mono text-xl/[1.375rem] font-semibold -tracking-4 md:text-2xl/[1.875rem]')
  313. .addClass(mainCaptionCls)
  314. .text('Usage Tracker');
  315.  
  316. usageHeading.after(genHr(), caption);
  317. debug(`decorateUsageCard: Added tracker caption. Check DOM for class '${mainCaptionCls}'. Current count in DOM: ${$c(mainCaptionCls).length}`);
  318. return true;
  319. }
  320. debug("decorateUsageCard: 'h2:contains(\"Usage\")' NOT FOUND.");
  321. return false;
  322. };
  323.  
  324. const addUsageTracker = () => {
  325. debug("addUsageTracker: Function START");
  326. const success = displayEnhancedTrackerData();
  327. debug(`addUsageTracker: displayEnhancedTrackerData returned ${success}`);
  328. return success;
  329. };
  330.  
  331. // --- Main Execution Logic ---
  332. const state = {
  333. addingUsageTrackerSucceeded: false,
  334. addingUsageTrackerAttempts: 0,
  335. };
  336.  
  337. const ATTEMPTS_LIMIT = 15;
  338. const ATTEMPTS_INTERVAL = 750;
  339. const ATTEMPTS_MAX_DELAY = 5000;
  340.  
  341. const main = () => {
  342. state.addingUsageTrackerAttempts++;
  343. log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts}...`);
  344.  
  345. const scheduleNextAttempt = () => {
  346. if (!state.addingUsageTrackerSucceeded && state.addingUsageTrackerAttempts < ATTEMPTS_LIMIT) {
  347. const delay = Math.min(ATTEMPTS_INTERVAL * (state.addingUsageTrackerAttempts), ATTEMPTS_MAX_DELAY);
  348. log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts} incomplete/failed. Retrying in ${delay}ms...`);
  349. setTimeout(main, delay);
  350. } else if (state.addingUsageTrackerSucceeded) {
  351. log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts} SUCCEEDED.`);
  352. } else {
  353. error(`Main Execution: All ${ATTEMPTS_LIMIT} attempts FAILED. Could not add Usage Tracker.`);
  354. }
  355. };
  356.  
  357. debug("Main Execution: Calling decorateUsageCard...");
  358. const decorationOkay = decorateUsageCard();
  359. debug(`Main Execution: decorateUsageCard returned ${decorationOkay}`);
  360.  
  361. if (!decorationOkay) {
  362. scheduleNextAttempt();
  363. return;
  364. }
  365.  
  366. debug("Main Execution: Checking for Recent Usage Events table...");
  367. const usageEventsTable = $('p:contains("Recent Usage Events")').closest('div').find('table tbody tr');
  368. if (usageEventsTable.length === 0) {
  369. debug("Main Execution: 'Recent Usage Events' table NOT FOUND YET.");
  370. scheduleNextAttempt();
  371. return;
  372. }
  373. debug(`Main Execution: 'Recent Usage Events' table FOUND (${usageEventsTable.length} rows).`);
  374.  
  375. debug("Main Execution: Attempting to add/update tracker UI via addUsageTracker...");
  376. try {
  377. state.addingUsageTrackerSucceeded = addUsageTracker();
  378. } catch (e) {
  379. error("Main Execution: CRITICAL ERROR during addUsageTracker call:", e);
  380. state.addingUsageTrackerSucceeded = false;
  381. }
  382. debug(`Main Execution: addUsageTracker process finished. Success: ${state.addingUsageTrackerSucceeded}`);
  383. scheduleNextAttempt();
  384. };
  385.  
  386. $(document).ready(() => {
  387. log('Document ready. Script starting...');
  388. // For manual debugging: assign to window instead of unsafeWindow for @grant none
  389. window.ut = {
  390. jq: $,
  391. parseEvents: parseUsageEventsTable,
  392. parseCosts: parseCurrentUsageCosts,
  393. getBase: getBaseUsageData
  394. };
  395. $('head').append($('<style>').text(styles));
  396. setTimeout(main, ATTEMPTS_INTERVAL);
  397. });
  398.  
  399. })();