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.

Устаревшая версия за 27.05.2025. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например 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);
    });

})();