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 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Cursor.com Usage Tracker (Enhanced)
// @author       monnef, Sonnet 3.5 (via Perplexity and Cursor), some help from Cursor Tab and Cursor Small, NoahBPeterson, Sonnet 3.7, Gemini
// @namespace    http://monnef.eu
// @version      0.5.9
// @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.
// @match        https://www.cursor.com/settings
// @grant        none
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @license      AGPL-3.0
// @icon         https://www.cursor.com/favicon-48x48.png
// ==/UserScript==

(function () {
    'use strict';

    const $ = jQuery.noConflict();

    const $c = (cls, parent) => $(`.${cls}`, parent);

    $.fn.nthParent = function (n) {
        return this.parents().eq(n - 1);
    };

    const log = (...messages) => {
        console.log(`[UsageTracker]`, ...messages);
    };

    const error = (...messages) => {
        console.error(`[UsageTracker]`, ...messages);
    };

    const debug = (...messages) => {
        console.debug(`[UsageTracker Debug]`, ...messages);
    };

    const genCssId = name => `ut-${name}`;

    // --- CSS Class Names ---
    const mainCaptionCls = genCssId('main-caption');
    const hrCls = genCssId('hr');
    const multiBarCls = genCssId('multi-bar');
    const barSegmentCls = genCssId('bar-segment');
    const tooltipCls = genCssId('tooltip');
    const statsContainerCls = genCssId('stats-container');
    const statItemCls = genCssId('stat-item');
    const enhancedTrackerContainerCls = genCssId('enhanced-tracker');
    const legendCls = genCssId('legend');
    const legendItemCls = genCssId('legend-item');
    const legendColorBoxCls = genCssId('legend-color-box');

    const colors = {
        cursor: {
            lightGray: '#e5e7eb',
            gray: '#a7a9ac',
            grayDark: '#333333',
        },
        modelColorPalette: [
            '#FF6F61', '#4CAF50', '#2196F3', '#FFEB3B', '#9C27B0',
            '#FF9800', '#00BCD4', '#E91E63', '#8BC34A', '#3F51B5',
            '#CDDC39', '#673AB7', '#FFC107', '#009688', '#FF5722',
            '#795548', '#607D8B', '#9E9E9E', '#F44336', '#4DD0E1',
            '#FFB74D', '#BA68C8', '#AED581', '#7986CB', '#A1887F'
        ]
    };

    const styles = `
    .${hrCls} { border: 0; height: 1px; background-color: #333333; margin: 15px 0; }
    .${statsContainerCls} { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 20px; margin: 15px 0; padding: 15px; background-color: #1a1a1a; border-radius: 8px; }
    .${statItemCls} { font-size: 14px; }
    .${statItemCls} .label { color: ${colors.cursor.gray}; }
    .${statItemCls} .value { color: white; font-weight: bold; }
    .${multiBarCls} {
        display: flex;
        width: 100%;
        height: 8px;
        background-color: ${colors.cursor.grayDark};
        border-radius: 9999px;
        margin: 10px 0;
    }
    .${barSegmentCls} {
        height: 100%;
        position: relative;
        transition: filter 0.2s ease-in-out;
    }
    .${barSegmentCls}:first-child { border-top-left-radius: 9999px; border-bottom-left-radius: 9999px; }
    .${barSegmentCls}:last-child { border-top-right-radius: 9999px; border-bottom-right-radius: 9999px; }

    .${barSegmentCls}:hover { filter: brightness(1.2); }
    .${barSegmentCls} .${tooltipCls} {
        visibility: hidden;
        width: max-content;
        background-color: black;
        color: #fff;
        text-align: center;
        border-radius: 6px;
        padding: 5px 10px;
        position: absolute;
        z-index: 50;
        bottom: 150%;
        left: 50%;
        transform: translateX(-50%);
        opacity: 0;
        transition: opacity 0.3s;
        border: 1px solid ${colors.cursor.gray};
        font-size: 12px;
        pointer-events: none;
    }
    .${barSegmentCls}:hover .${tooltipCls} {
        visibility: visible;
        opacity: 1;
    }
    .${barSegmentCls} .${tooltipCls}::after {
        content: "";
        position: absolute;
        top: 100%;
        left: 50%;
        margin-left: -5px;
        border-width: 5px;
        border-style: solid;
        border-color: black transparent transparent transparent;
    }
    .${legendCls} {
        margin-top: 15px;
        padding: 10px;
        background-color: #1e1e1e;
        border-radius: 6px;
        display: flex;
        flex-wrap: wrap;
        gap: 8px 15px;
    }
    .${legendItemCls} {
        display: flex;
        align-items: center;
        font-size: 12px;
        color: ${colors.cursor.lightGray};
    }
    .${legendColorBoxCls} {
        width: 12px;
        height: 12px;
        margin-right: 6px;
        border: 1px solid #444;
        flex-shrink: 0;
    }
  `;

    const genHr = () => $('<hr>').addClass(hrCls);

    // --- Data Parsing Functions ---
    const parseUsageEventsTable = () => {
        const modelUsage = {};
        let totalPaidRequests = 0;
        let totalRequests = 0;
        let erroredRequests = 0;

        const table = $('p:contains("Recent Usage Events")').closest('div').find('table tbody tr');

        if (table.length === 0) {
            error("Recent Usage Events table: Not found or empty.");
            return { modelUsage, totalPaidRequests, totalRequests, erroredRequests };
        }

        table.each((_, row) => {
            const $row = $(row);
            const model = $row.find('td:eq(1)').text().trim();
            const status = $row.find('td:eq(2)').text().trim();
            const requestsStr = $row.find('td:eq(3)').text().trim();
            const requests = parseFloat(requestsStr) || 0;

            if (status !== 'Errored, Not Charged' && model) {
                totalRequests += requests;
                if (!modelUsage[model]) {
                    modelUsage[model] = { count: 0, cost: 0 };
                }
                modelUsage[model].count += requests;

                if (status === 'Usage-based') {
                    totalPaidRequests += requests;
                }
            } else if (status === 'Errored, Not Charged') {
                erroredRequests += 1;
            }
        });
        return { modelUsage, totalPaidRequests, totalRequests, erroredRequests };
    };

    const parseCurrentUsageCosts = (modelUsage) => {
        let overallTotalCost = 0;
        const costTable = $('p:contains("Current Usage"):last').closest('div').find('table tbody tr');

        if (costTable.length === 0) {
             error("Current Usage (Cost) table: Not found or empty.");
             for (const modelKey in modelUsage) {
                if (!modelUsage[modelKey].hasOwnProperty('cost')) {
                    modelUsage[modelKey].cost = 0;
                }
             }
             return { overallTotalCost };
        }

        for (const modelKey in modelUsage) {
            modelUsage[modelKey].cost = 0;
        }
        if (!modelUsage['Extra/Other Premium']) {
            modelUsage['Extra/Other Premium'] = { count: 0, cost: 0 };
        }
         if (!modelUsage['Other Costs']) {
            modelUsage['Other Costs'] = { count: 0, cost: 0 };
        }

        costTable.each((_, row) => {
            const $row = $(row);
            const description = $row.find('td:eq(0)').text().trim().toLowerCase();
            const costStr = $row.find('td:eq(1)').text().trim().replace('$', '');
            const cost = parseFloat(costStr) || 0;
            overallTotalCost += cost;
            if (cost <= 0 && description.includes('paid for')) {
                return;
            }

            let foundModel = false;
            for (const modelKey in modelUsage) {
                if (modelKey === 'Extra/Other Premium' || modelKey === 'Other Costs') continue;
                if (description.includes(modelKey.toLowerCase())) {
                    modelUsage[modelKey].cost += cost;
                    foundModel = true;
                }
            }

            if (!foundModel && (description.includes('extra') || description.includes('premium')) && cost > 0 ) {
                 modelUsage['Extra/Other Premium'].cost += cost;
                 foundModel = true;
            }
            if (!foundModel && cost > 0) {
                modelUsage['Other Costs'].cost += cost;
            }
        });

        if (modelUsage['Extra/Other Premium'] && modelUsage['Extra/Other Premium'].cost === 0 && modelUsage['Extra/Other Premium'].count === 0) {
            delete modelUsage['Extra/Other Premium'];
        }
        if (modelUsage['Other Costs'] && modelUsage['Other Costs'].cost === 0 && modelUsage['Other Costs'].count === 0) {
            delete modelUsage['Other Costs'];
        }

        return { overallTotalCost };
    };

    const getBaseUsageData = () => {
        const premiumLabel = $('span:contains("Premium models")').first();
        if (premiumLabel.length === 0) {
            return {};
        }
        const usageSpan = premiumLabel.siblings('span').last();
        const usageText = usageSpan.text();
        const regex = /(\d+) \/ (\d+)/;
        const matches = usageText.match(regex);
        if (matches && matches.length === 3) {
            return { used: parseInt(matches[1], 10), total: parseInt(matches[2], 10) };
        }
        return {};
    };

    // --- Display Functions ---

    const createGenericProgressBar = (modelUsage, weightField, modelToColorMap) => {
        const barContainer = $('<div>').addClass(multiBarCls);
        const totalWeight = Object.values(modelUsage)
                                .reduce((sum, model) => sum + (model[weightField] || 0), 0);

        if (totalWeight === 0) {
            return barContainer.text(`No data for ${weightField}-weighted bar.`).css({ height: 'auto', padding: '5px' });
        }

        const sortedModels = Object.entries(modelUsage)
            .filter(([_, data]) => (data[weightField] || 0) > 0)
            .sort(([, a], [, b]) => (b[weightField] || 0) - (a[weightField] || 0));

        sortedModels.forEach((entry) => {
            const [model, data] = entry;
            const percentage = (data[weightField] / totalWeight) * 100;
            const reqCount = (data.count || 0).toFixed(1);
            const costAmount = (data.cost || 0).toFixed(2);
            const color = modelToColorMap[model] || colors.cursor.gray;

            const tooltipText = `${model}: ${reqCount} reqs ($${costAmount})`;
            const tooltip = $('<span>').addClass(tooltipCls).text(tooltipText);
            const segment = $('<div>')
                .addClass(barSegmentCls)
                .css({ width: `${percentage}%`, backgroundColor: color })
                .append(tooltip);
            barContainer.append(segment);
        });
        return barContainer;
    };

     const createLegend = (modelToColorMap, modelUsage) => {
        const legendContainer = $('<div>').addClass(legendCls);

        // Get models that are in the color map (meaning they have some usage/cost)
        const modelsInLegend = Object.keys(modelToColorMap);

        // Sort these models by their usage count (descending)
        const sortedModelsForLegend = modelsInLegend.sort((modelA, modelB) => {
            const countA = modelUsage[modelA]?.count || 0;
            const countB = modelUsage[modelB]?.count || 0;
            return countB - countA; // Sort descending by request count
        });

        for (const model of sortedModelsForLegend) {
            const color = modelToColorMap[model];
            const count = (modelUsage[model]?.count || 0).toFixed(1);
            const cost = (modelUsage[model]?.cost || 0).toFixed(2);

            const colorBox = $('<span>')
                .addClass(legendColorBoxCls)
                .css('background-color', color);
            const legendItem = $('<div>')
                .addClass(legendItemCls)
                .append(colorBox)
                .append(document.createTextNode(`${model}`));
            legendContainer.append(legendItem);
        }
        return legendContainer;
    };

    const displayEnhancedTrackerData = () => {
        debug('displayEnhancedTrackerData: Function START');
        const tracker = $c(mainCaptionCls);
        if (tracker.length === 0) {
            error('displayEnhancedTrackerData: Main caption element NOT FOUND.');
            return false;
        }
        debug(`displayEnhancedTrackerData: Found tracker caption element.`);
        tracker.siblings(`.${enhancedTrackerContainerCls}`).remove();

        const { modelUsage, totalPaidRequests, totalRequests, erroredRequests } = parseUsageEventsTable();
        const { overallTotalCost } = parseCurrentUsageCosts(modelUsage);
        const baseUsage = getBaseUsageData();

        const modelToColorMap = {};
        let colorPaletteIndex = 0;
        const uniqueModelsInUsage = Object.keys(modelUsage)
                                      .filter(modelName => (modelUsage[modelName].count || 0) > 0 || (modelUsage[modelName].cost || 0) > 0);

        for (const modelName of uniqueModelsInUsage) {
            modelToColorMap[modelName] = colors.modelColorPalette[colorPaletteIndex % colors.modelColorPalette.length];
            colorPaletteIndex++;
        }
        debug('displayEnhancedTrackerData: modelToColorMap built:', modelToColorMap);

        const container = $('<div>').addClass(enhancedTrackerContainerCls);
        const statsContainer = $('<div>').addClass(statsContainerCls);

        const addStat = (label, value) => {
             statsContainer.append(
                $('<div>').addClass(statItemCls).append(
                    $('<span>').addClass('label').text(`${label}: `),
                    $('<span>').addClass('value').text(value)
                )
            );
        };

        addStat('Usage-Based Weighted Requests', totalPaidRequests.toFixed(1));
        addStat('Plan Premium Requests', baseUsage.used !== undefined ? `${baseUsage.used} / ${baseUsage.total || 'N/A'}` : 'N/A');
        addStat('Total Weighted Requests (Events)', totalRequests.toFixed(1));
        addStat('Errored Requests (Events)', erroredRequests);
        addStat('Total Billed Cost (This Cycle)', `$${overallTotalCost.toFixed(2)}`);


        container.append(statsContainer);
        container.append($('<p>').css({ fontSize: '13px', color: colors.cursor.gray, marginTop: '15px', marginBottom: '2px' })
            .text('Model Usage Breakdown (Weighted by Requests):')
        );
        container.append(createGenericProgressBar(modelUsage, 'count', modelToColorMap));


        if (Object.keys(modelToColorMap).length > 0) {
            // Pass modelUsage to createLegend to access counts for sorting and display
            container.append(createLegend(modelToColorMap, modelUsage));
        }

        debug('displayEnhancedTrackerData: Stats container PREPARED. Appending to DOM...');
        tracker.after(container);
        debug('displayEnhancedTrackerData: Enhanced tracker data supposedly displayed.');
        return true;
    };

    // --- Core Script Functions ---
    const decorateUsageCard = () => {
        debug("decorateUsageCard: Function START");
        if ($c(mainCaptionCls).length > 0) {
            debug("decorateUsageCard: Card already decorated.");
            return true;
        }
        const usageHeading = $('h2:contains("Usage")');
        if (usageHeading.length > 0) {
            debug(`decorateUsageCard: Found 'h2:contains("Usage")' (count: ${usageHeading.length}). Decorating...`);
            const caption = $('<div>')
                .addClass('font-medium gt-standard-mono text-xl/[1.375rem] font-semibold -tracking-4 md:text-2xl/[1.875rem]')
                .addClass(mainCaptionCls)
                .text('Usage Tracker');

            usageHeading.after(genHr(), caption);
            debug(`decorateUsageCard: Added tracker caption. Check DOM for class '${mainCaptionCls}'. Current count in DOM: ${$c(mainCaptionCls).length}`);
            return true;
        }
        debug("decorateUsageCard: 'h2:contains(\"Usage\")' NOT FOUND.");
        return false;
    };

    const addUsageTracker = () => {
        debug("addUsageTracker: Function START");
        const success = displayEnhancedTrackerData();
        debug(`addUsageTracker: displayEnhancedTrackerData returned ${success}`);
        return success;
    };

    // --- Main Execution Logic ---
    const state = {
        addingUsageTrackerSucceeded: false,
        addingUsageTrackerAttempts: 0,
    };

    const ATTEMPTS_LIMIT = 15;
    const ATTEMPTS_INTERVAL = 750;
    const ATTEMPTS_MAX_DELAY = 5000;

    const main = () => {
        state.addingUsageTrackerAttempts++;
        log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts}...`);

        const scheduleNextAttempt = () => {
            if (!state.addingUsageTrackerSucceeded && state.addingUsageTrackerAttempts < ATTEMPTS_LIMIT) {
                const delay = Math.min(ATTEMPTS_INTERVAL * (state.addingUsageTrackerAttempts), ATTEMPTS_MAX_DELAY);
                log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts} incomplete/failed. Retrying in ${delay}ms...`);
                setTimeout(main, delay);
            } else if (state.addingUsageTrackerSucceeded) {
                log(`Main Execution: Attempt ${state.addingUsageTrackerAttempts} SUCCEEDED.`);
            } else {
                error(`Main Execution: All ${ATTEMPTS_LIMIT} attempts FAILED. Could not add Usage Tracker.`);
            }
        };

        debug("Main Execution: Calling decorateUsageCard...");
        const decorationOkay = decorateUsageCard();
        debug(`Main Execution: decorateUsageCard returned ${decorationOkay}`);

        if (!decorationOkay) {
             scheduleNextAttempt();
             return;
        }

        debug("Main Execution: Checking for Recent Usage Events table...");
        const usageEventsTable = $('p:contains("Recent Usage Events")').closest('div').find('table tbody tr');
        if (usageEventsTable.length === 0) {
            debug("Main Execution: 'Recent Usage Events' table NOT FOUND YET.");
            scheduleNextAttempt();
            return;
        }
        debug(`Main Execution: 'Recent Usage Events' table FOUND (${usageEventsTable.length} rows).`);

        debug("Main Execution: Attempting to add/update tracker UI via addUsageTracker...");
        try {
            state.addingUsageTrackerSucceeded = addUsageTracker();
        } catch (e) {
            error("Main Execution: CRITICAL ERROR during addUsageTracker call:", e);
            state.addingUsageTrackerSucceeded = false;
        }
        debug(`Main Execution: addUsageTracker process finished. Success: ${state.addingUsageTrackerSucceeded}`);
        scheduleNextAttempt();
    };

    $(document).ready(() => {
        log('Document ready. Script starting...');
        window.ut = {
            jq: $,
            parseEvents: parseUsageEventsTable,
            parseCosts: parseCurrentUsageCosts,
            getBase: getBaseUsageData
        };
        $('head').append($('<style>').text(styles));
        setTimeout(main, ATTEMPTS_INTERVAL);
    });

})();