Cursor.com Usage Tracker (Enhanced)

Tracks and displays usage statistics and detailed model costs for Premium models on Cursor.com, with consistent model colors and a legend sorted by usage.

目前為 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.9
  6. // @description Tracks and displays usage statistics and detailed model costs for Premium models on Cursor.com, with consistent model colors and a legend sorted by usage.
  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');
  48. const legendCls = genCssId('legend');
  49. const legendItemCls = genCssId('legend-item');
  50. const legendColorBoxCls = genCssId('legend-color-box');
  51.  
  52. const colors = {
  53. cursor: {
  54. lightGray: '#e5e7eb',
  55. gray: '#a7a9ac',
  56. grayDark: '#333333',
  57. },
  58. modelColorPalette: [
  59. '#FF6F61', '#4CAF50', '#2196F3', '#FFEB3B', '#9C27B0',
  60. '#FF9800', '#00BCD4', '#E91E63', '#8BC34A', '#3F51B5',
  61. '#CDDC39', '#673AB7', '#FFC107', '#009688', '#FF5722',
  62. '#795548', '#607D8B', '#9E9E9E', '#F44336', '#4DD0E1',
  63. '#FFB74D', '#BA68C8', '#AED581', '#7986CB', '#A1887F'
  64. ]
  65. };
  66.  
  67. const styles = `
  68. .${hrCls} { border: 0; height: 1px; background-color: #333333; margin: 15px 0; }
  69. .${statsContainerCls} { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 20px; margin: 15px 0; padding: 15px; background-color: #1a1a1a; border-radius: 8px; }
  70. .${statItemCls} { font-size: 14px; }
  71. .${statItemCls} .label { color: ${colors.cursor.gray}; }
  72. .${statItemCls} .value { color: white; font-weight: bold; }
  73. .${multiBarCls} {
  74. display: flex;
  75. width: 100%;
  76. height: 8px;
  77. background-color: ${colors.cursor.grayDark};
  78. border-radius: 9999px;
  79. margin: 10px 0;
  80. }
  81. .${barSegmentCls} {
  82. height: 100%;
  83. position: relative;
  84. transition: filter 0.2s ease-in-out;
  85. }
  86. .${barSegmentCls}:first-child { border-top-left-radius: 9999px; border-bottom-left-radius: 9999px; }
  87. .${barSegmentCls}:last-child { border-top-right-radius: 9999px; border-bottom-right-radius: 9999px; }
  88.  
  89. .${barSegmentCls}:hover { filter: brightness(1.2); }
  90. .${barSegmentCls} .${tooltipCls} {
  91. visibility: hidden;
  92. width: max-content;
  93. background-color: black;
  94. color: #fff;
  95. text-align: center;
  96. border-radius: 6px;
  97. padding: 5px 10px;
  98. position: absolute;
  99. z-index: 50;
  100. bottom: 150%;
  101. left: 50%;
  102. transform: translateX(-50%);
  103. opacity: 0;
  104. transition: opacity 0.3s;
  105. border: 1px solid ${colors.cursor.gray};
  106. font-size: 12px;
  107. pointer-events: none;
  108. }
  109. .${barSegmentCls}:hover .${tooltipCls} {
  110. visibility: visible;
  111. opacity: 1;
  112. }
  113. .${barSegmentCls} .${tooltipCls}::after {
  114. content: "";
  115. position: absolute;
  116. top: 100%;
  117. left: 50%;
  118. margin-left: -5px;
  119. border-width: 5px;
  120. border-style: solid;
  121. border-color: black transparent transparent transparent;
  122. }
  123. .${legendCls} {
  124. margin-top: 15px;
  125. padding: 10px;
  126. background-color: #1e1e1e;
  127. border-radius: 6px;
  128. display: flex;
  129. flex-wrap: wrap;
  130. gap: 8px 15px;
  131. }
  132. .${legendItemCls} {
  133. display: flex;
  134. align-items: center;
  135. font-size: 12px;
  136. color: ${colors.cursor.lightGray};
  137. }
  138. .${legendColorBoxCls} {
  139. width: 12px;
  140. height: 12px;
  141. margin-right: 6px;
  142. border: 1px solid #444;
  143. flex-shrink: 0;
  144. }
  145. `;
  146.  
  147. const genHr = () => $('<hr>').addClass(hrCls);
  148.  
  149. // --- Data Parsing Functions ---
  150. const parseUsageEventsTable = () => {
  151. const modelUsage = {};
  152. let totalPaidRequests = 0;
  153. let totalRequests = 0;
  154. let erroredRequests = 0;
  155.  
  156. const table = $('p:contains("Recent Usage Events")').closest('div').find('table tbody tr');
  157.  
  158. if (table.length === 0) {
  159. error("Recent Usage Events table: Not found or empty.");
  160. return { modelUsage, totalPaidRequests, totalRequests, erroredRequests };
  161. }
  162.  
  163. table.each((_, row) => {
  164. const $row = $(row);
  165. const model = $row.find('td:eq(1)').text().trim();
  166. const status = $row.find('td:eq(2)').text().trim();
  167. const requestsStr = $row.find('td:eq(3)').text().trim();
  168. const requests = parseFloat(requestsStr) || 0;
  169.  
  170. if (status !== 'Errored, Not Charged' && model) {
  171. totalRequests += requests;
  172. if (!modelUsage[model]) {
  173. modelUsage[model] = { count: 0, cost: 0 };
  174. }
  175. modelUsage[model].count += requests;
  176.  
  177. if (status === 'Usage-based') {
  178. totalPaidRequests += requests;
  179. }
  180. } else if (status === 'Errored, Not Charged') {
  181. erroredRequests += 1;
  182. }
  183. });
  184. return { modelUsage, totalPaidRequests, totalRequests, erroredRequests };
  185. };
  186.  
  187. const parseCurrentUsageCosts = (modelUsage) => {
  188. let overallTotalCost = 0;
  189. const costTable = $('p:contains("Current Usage"):last').closest('div').find('table tbody tr');
  190.  
  191. if (costTable.length === 0) {
  192. error("Current Usage (Cost) table: Not found or empty.");
  193. for (const modelKey in modelUsage) {
  194. if (!modelUsage[modelKey].hasOwnProperty('cost')) {
  195. modelUsage[modelKey].cost = 0;
  196. }
  197. }
  198. return { overallTotalCost };
  199. }
  200.  
  201. for (const modelKey in modelUsage) {
  202. modelUsage[modelKey].cost = 0;
  203. }
  204. if (!modelUsage['Extra/Other Premium']) {
  205. modelUsage['Extra/Other Premium'] = { count: 0, cost: 0 };
  206. }
  207. if (!modelUsage['Other Costs']) {
  208. modelUsage['Other Costs'] = { count: 0, cost: 0 };
  209. }
  210.  
  211. costTable.each((_, row) => {
  212. const $row = $(row);
  213. const description = $row.find('td:eq(0)').text().trim().toLowerCase();
  214. const costStr = $row.find('td:eq(1)').text().trim().replace('$', '');
  215. const cost = parseFloat(costStr) || 0;
  216. overallTotalCost += cost;
  217. if (cost <= 0 && description.includes('paid for')) {
  218. return;
  219. }
  220.  
  221. let foundModel = false;
  222. for (const modelKey in modelUsage) {
  223. if (modelKey === 'Extra/Other Premium' || modelKey === 'Other Costs') continue;
  224. if (description.includes(modelKey.toLowerCase())) {
  225. modelUsage[modelKey].cost += cost;
  226. foundModel = true;
  227. }
  228. }
  229.  
  230. if (!foundModel && (description.includes('extra') || description.includes('premium')) && cost > 0 ) {
  231. modelUsage['Extra/Other Premium'].cost += cost;
  232. foundModel = true;
  233. }
  234. if (!foundModel && cost > 0) {
  235. modelUsage['Other Costs'].cost += cost;
  236. }
  237. });
  238.  
  239. if (modelUsage['Extra/Other Premium'] && modelUsage['Extra/Other Premium'].cost === 0 && modelUsage['Extra/Other Premium'].count === 0) {
  240. delete modelUsage['Extra/Other Premium'];
  241. }
  242. if (modelUsage['Other Costs'] && modelUsage['Other Costs'].cost === 0 && modelUsage['Other Costs'].count === 0) {
  243. delete modelUsage['Other Costs'];
  244. }
  245.  
  246. return { overallTotalCost };
  247. };
  248.  
  249. const getBaseUsageData = () => {
  250. const premiumLabel = $('span:contains("Premium models")').first();
  251. if (premiumLabel.length === 0) {
  252. return {};
  253. }
  254. const usageSpan = premiumLabel.siblings('span').last();
  255. const usageText = usageSpan.text();
  256. const regex = /(\d+) \/ (\d+)/;
  257. const matches = usageText.match(regex);
  258. if (matches && matches.length === 3) {
  259. return { used: parseInt(matches[1], 10), total: parseInt(matches[2], 10) };
  260. }
  261. return {};
  262. };
  263.  
  264. // --- Display Functions ---
  265.  
  266. const createGenericProgressBar = (modelUsage, weightField, modelToColorMap) => {
  267. const barContainer = $('<div>').addClass(multiBarCls);
  268. const totalWeight = Object.values(modelUsage)
  269. .reduce((sum, model) => sum + (model[weightField] || 0), 0);
  270.  
  271. if (totalWeight === 0) {
  272. return barContainer.text(`No data for ${weightField}-weighted bar.`).css({ height: 'auto', padding: '5px' });
  273. }
  274.  
  275. const sortedModels = Object.entries(modelUsage)
  276. .filter(([_, data]) => (data[weightField] || 0) > 0)
  277. .sort(([, a], [, b]) => (b[weightField] || 0) - (a[weightField] || 0));
  278.  
  279. sortedModels.forEach((entry) => {
  280. const [model, data] = entry;
  281. const percentage = (data[weightField] / totalWeight) * 100;
  282. const reqCount = (data.count || 0).toFixed(1);
  283. const costAmount = (data.cost || 0).toFixed(2);
  284. const color = modelToColorMap[model] || colors.cursor.gray;
  285.  
  286. const tooltipText = `${model}: ${reqCount} reqs ($${costAmount})`;
  287. const tooltip = $('<span>').addClass(tooltipCls).text(tooltipText);
  288. const segment = $('<div>')
  289. .addClass(barSegmentCls)
  290. .css({ width: `${percentage}%`, backgroundColor: color })
  291. .append(tooltip);
  292. barContainer.append(segment);
  293. });
  294. return barContainer;
  295. };
  296.  
  297. const createLegend = (modelToColorMap, modelUsage) => {
  298. const legendContainer = $('<div>').addClass(legendCls);
  299.  
  300. // Get models that are in the color map (meaning they have some usage/cost)
  301. const modelsInLegend = Object.keys(modelToColorMap);
  302.  
  303. // Sort these models by their usage count (descending)
  304. const sortedModelsForLegend = modelsInLegend.sort((modelA, modelB) => {
  305. const countA = modelUsage[modelA]?.count || 0;
  306. const countB = modelUsage[modelB]?.count || 0;
  307. return countB - countA; // Sort descending by request count
  308. });
  309.  
  310. for (const model of sortedModelsForLegend) {
  311. const color = modelToColorMap[model];
  312. const count = (modelUsage[model]?.count || 0).toFixed(1);
  313. const cost = (modelUsage[model]?.cost || 0).toFixed(2);
  314.  
  315. const colorBox = $('<span>')
  316. .addClass(legendColorBoxCls)
  317. .css('background-color', color);
  318. const legendItem = $('<div>')
  319. .addClass(legendItemCls)
  320. .append(colorBox)
  321. .append(document.createTextNode(`${model}`));
  322. legendContainer.append(legendItem);
  323. }
  324. return legendContainer;
  325. };
  326.  
  327. const displayEnhancedTrackerData = () => {
  328. debug('displayEnhancedTrackerData: Function START');
  329. const tracker = $c(mainCaptionCls);
  330. if (tracker.length === 0) {
  331. error('displayEnhancedTrackerData: Main caption element NOT FOUND.');
  332. return false;
  333. }
  334. debug(`displayEnhancedTrackerData: Found tracker caption element.`);
  335. tracker.siblings(`.${enhancedTrackerContainerCls}`).remove();
  336.  
  337. const { modelUsage, totalPaidRequests, totalRequests, erroredRequests } = parseUsageEventsTable();
  338. const { overallTotalCost } = parseCurrentUsageCosts(modelUsage);
  339. const baseUsage = getBaseUsageData();
  340.  
  341. const modelToColorMap = {};
  342. let colorPaletteIndex = 0;
  343. const uniqueModelsInUsage = Object.keys(modelUsage)
  344. .filter(modelName => (modelUsage[modelName].count || 0) > 0 || (modelUsage[modelName].cost || 0) > 0);
  345.  
  346. for (const modelName of uniqueModelsInUsage) {
  347. modelToColorMap[modelName] = colors.modelColorPalette[colorPaletteIndex % colors.modelColorPalette.length];
  348. colorPaletteIndex++;
  349. }
  350. debug('displayEnhancedTrackerData: modelToColorMap built:', modelToColorMap);
  351.  
  352. const container = $('<div>').addClass(enhancedTrackerContainerCls);
  353. const statsContainer = $('<div>').addClass(statsContainerCls);
  354.  
  355. const addStat = (label, value) => {
  356. statsContainer.append(
  357. $('<div>').addClass(statItemCls).append(
  358. $('<span>').addClass('label').text(`${label}: `),
  359. $('<span>').addClass('value').text(value)
  360. )
  361. );
  362. };
  363.  
  364. addStat('Usage-Based Weighted Requests', totalPaidRequests.toFixed(1));
  365. addStat('Plan Premium Requests', baseUsage.used !== undefined ? `${baseUsage.used} / ${baseUsage.total || 'N/A'}` : 'N/A');
  366. addStat('Total Weighted Requests (Events)', totalRequests.toFixed(1));
  367. addStat('Errored Requests (Events)', erroredRequests);
  368. addStat('Total Billed Cost (This Cycle)', `$${overallTotalCost.toFixed(2)}`);
  369.  
  370.  
  371. container.append(statsContainer);
  372. container.append($('<p>').css({ fontSize: '13px', color: colors.cursor.gray, marginTop: '15px', marginBottom: '2px' })
  373. .text('Model Usage Breakdown (Weighted by Requests):')
  374. );
  375. container.append(createGenericProgressBar(modelUsage, 'count', modelToColorMap));
  376.  
  377.  
  378. if (Object.keys(modelToColorMap).length > 0) {
  379. // Pass modelUsage to createLegend to access counts for sorting and display
  380. container.append(createLegend(modelToColorMap, modelUsage));
  381. }
  382.  
  383. debug('displayEnhancedTrackerData: Stats container PREPARED. Appending to DOM...');
  384. tracker.after(container);
  385. debug('displayEnhancedTrackerData: Enhanced tracker data supposedly displayed.');
  386. return true;
  387. };
  388.  
  389. // --- Core Script Functions ---
  390. const decorateUsageCard = () => {
  391. debug("decorateUsageCard: Function START");
  392. if ($c(mainCaptionCls).length > 0) {
  393. debug("decorateUsageCard: Card already decorated.");
  394. return true;
  395. }
  396. const usageHeading = $('h2:contains("Usage")');
  397. if (usageHeading.length > 0) {
  398. debug(`decorateUsageCard: Found 'h2:contains("Usage")' (count: ${usageHeading.length}). Decorating...`);
  399. const caption = $('<div>')
  400. .addClass('font-medium gt-standard-mono text-xl/[1.375rem] font-semibold -tracking-4 md:text-2xl/[1.875rem]')
  401. .addClass(mainCaptionCls)
  402. .text('Usage Tracker');
  403.  
  404. usageHeading.after(genHr(), caption);
  405. debug(`decorateUsageCard: Added tracker caption. Check DOM for class '${mainCaptionCls}'. Current count in DOM: ${$c(mainCaptionCls).length}`);
  406. return true;
  407. }
  408. debug("decorateUsageCard: 'h2:contains(\"Usage\")' NOT FOUND.");
  409. return false;
  410. };
  411.  
  412. const addUsageTracker = () => {
  413. debug("addUsageTracker: Function START");
  414. const success = displayEnhancedTrackerData();
  415. debug(`addUsageTracker: displayEnhancedTrackerData returned ${success}`);
  416. return success;
  417. };
  418.  
  419. // --- Main Execution Logic ---
  420. const state = {
  421. addingUsageTrackerSucceeded: false,
  422. addingUsageTrackerAttempts: 0,
  423. };
  424.  
  425. const ATTEMPTS_LIMIT = 15;
  426. const ATTEMPTS_INTERVAL = 750;
  427. const ATTEMPTS_MAX_DELAY = 5000;
  428.  
  429. const main = () => {
  430. state.addingUsageTrackerAttempts++;
  431. log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts}...`);
  432.  
  433. const scheduleNextAttempt = () => {
  434. if (!state.addingUsageTrackerSucceeded && state.addingUsageTrackerAttempts < ATTEMPTS_LIMIT) {
  435. const delay = Math.min(ATTEMPTS_INTERVAL * (state.addingUsageTrackerAttempts), ATTEMPTS_MAX_DELAY);
  436. log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts} incomplete/failed. Retrying in ${delay}ms...`);
  437. setTimeout(main, delay);
  438. } else if (state.addingUsageTrackerSucceeded) {
  439. log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts} SUCCEEDED.`);
  440. } else {
  441. error(`Main Execution: All ${ATTEMPTS_LIMIT} attempts FAILED. Could not add Usage Tracker.`);
  442. }
  443. };
  444.  
  445. debug("Main Execution: Calling decorateUsageCard...");
  446. const decorationOkay = decorateUsageCard();
  447. debug(`Main Execution: decorateUsageCard returned ${decorationOkay}`);
  448.  
  449. if (!decorationOkay) {
  450. scheduleNextAttempt();
  451. return;
  452. }
  453.  
  454. debug("Main Execution: Checking for Recent Usage Events table...");
  455. const usageEventsTable = $('p:contains("Recent Usage Events")').closest('div').find('table tbody tr');
  456. if (usageEventsTable.length === 0) {
  457. debug("Main Execution: 'Recent Usage Events' table NOT FOUND YET.");
  458. scheduleNextAttempt();
  459. return;
  460. }
  461. debug(`Main Execution: 'Recent Usage Events' table FOUND (${usageEventsTable.length} rows).`);
  462.  
  463. debug("Main Execution: Attempting to add/update tracker UI via addUsageTracker...");
  464. try {
  465. state.addingUsageTrackerSucceeded = addUsageTracker();
  466. } catch (e) {
  467. error("Main Execution: CRITICAL ERROR during addUsageTracker call:", e);
  468. state.addingUsageTrackerSucceeded = false;
  469. }
  470. debug(`Main Execution: addUsageTracker process finished. Success: ${state.addingUsageTrackerSucceeded}`);
  471. scheduleNextAttempt();
  472. };
  473.  
  474. $(document).ready(() => {
  475. log('Document ready. Script starting...');
  476. window.ut = {
  477. jq: $,
  478. parseEvents: parseUsageEventsTable,
  479. parseCosts: parseCurrentUsageCosts,
  480. getBase: getBaseUsageData
  481. };
  482. $('head').append($('<style>').text(styles));
  483. setTimeout(main, ATTEMPTS_INTERVAL);
  484. });
  485.  
  486. })();