Skip to content

Instantly share code, notes, and snippets.

@VapidLinus
Last active August 2, 2025 10:08
Show Gist options
  • Save VapidLinus/0f7f2b5b343b331680229f139630c357 to your computer and use it in GitHub Desktop.
Save VapidLinus/0f7f2b5b343b331680229f139630c357 to your computer and use it in GitHub Desktop.
A collapsible, auto-refreshing, theme-integrated usage widget for Kagi Assistant.
// ==UserScript==
// @name Kagi Assistant AI Usage Tracker
// @namespace http://tampermonkey.net/
// @version 14.0
// @description A collapsible, auto-refreshing, theme-integrated usage widget for Kagi Assistant.
// @author Gemini 2.5 Pro & VapidLinus
// @match https://kagi.com/assistant*
// @connect kagi.com
// @grant GM.xmlHttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @icon https://kagi.com/favicon-assistant-32x32.png
// ==/UserScript==
(function() {
'use strict';
// --- CONFIGURATION ---
const PACING_GREEN_LIMIT = 1; // Anything below this percentage over budget is green
const PACING_ORANGE_LIMIT = 15; // Anything between GREEN_LIMIT and this is orange
const INACTIVITY_DELAY = 5000;
const REFRESH_INTERVAL = 30000;
const CONTAINER_ID = 'kagi-usage-tracker-container';
const STORAGE_KEY_COLLAPSED = 'kagiUsageWidgetCollapsed';
let lastMetrics = null; // Cache for instantaneous rendering
/**
* Injects CSS rules for the widget.
*/
function injectCss() {
const style = document.createElement('style');
style.textContent = `
#${CONTAINER_ID} {
position: fixed; top: 5rem; right: 2rem;
z-index: 40; font-family: inherit;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
}
#${CONTAINER_ID}.expanded {
width: 13.75rem; background-color: var(--app-bg);
border: 1px solid var(--primary-100); padding: 0.625rem 0.9375rem;
border-radius: 0.5rem; box-shadow: 0 1px 3px var(--box-shadow);
color: var(--primary); transform: translateX(0);
font-size: 0.8125rem;
}
#${CONTAINER_ID}.collapsed {
/* The container is just a placeholder for the button */
}
#${CONTAINER_ID}.kagi-widget-hidden {
opacity: 0 !important; pointer-events: none;
transform: translateX(1rem);
}
.kagi-widget-control-btn {
position: absolute; top: 0.5rem; right: 0.5rem;
width: 1.5rem; height: 1.5rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
cursor: pointer; background-color: transparent;
transition: background-color 0.2s ease;
}
.kagi-widget-control-btn:hover { background-color: var(--primary-100); }
.kagi-widget-control-btn svg { stroke: var(--primary-600); }
/* Styles for the collapsed button to ensure consistency */
#${CONTAINER_ID}.collapsed .kagi-widget-control-btn {
position: static;
width: 2.5rem; height: 2.5rem;
background-color: var(--app-bg);
box-shadow: 0 1px 3px var(--box-shadow);
border: 1px solid var(--primary-100);
}
#${CONTAINER_ID}.collapsed .kagi-widget-control-btn svg {
fill: var(--primary-600); /* Default color */
stroke: none;
transition: fill 0.2s ease-in-out;
}
#${CONTAINER_ID}.collapsed .kagi-widget-control-btn:hover {
background-color: var(--primary-100);
}
`;
document.head.appendChild(style);
}
/**
* Fetches and parses the Kagi billing page.
*/
async function fetchBillingData() {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: "GET", url: "https://kagi.com/settings/billing",
onload: response => {
if (response.status !== 200) return reject(`HTTP Status ${response.status}`);
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, "text/html");
const costTitle = Array.from(doc.querySelectorAll('.billing_box_count_title')).find(el => el.textContent.trim().includes('Total AI cost'));
const renewalEl = doc.querySelector('.billing_box_footer .sec_1 b');
if (!costTitle || !renewalEl) return reject("Could not find required elements.");
const costText = costTitle.nextElementSibling.querySelector('.billing_box_count_num_1').textContent.trim();
const renewalDateText = renewalEl.textContent.trim();
resolve({ costText, renewalDateText });
},
onerror: error => reject(`Network error: ${error}`)
});
});
}
/**
* Calculates all necessary metrics from the raw data using the new pacing logic.
*/
function calculateMetrics({ costText, renewalDateText }) {
const [usedCostStr, totalBudgetStr] = costText.replace('$', '').replace(/,/g, '.').split('/');
const usedCost = parseFloat(usedCostStr);
const totalBudget = parseFloat(totalBudgetStr);
const renewalDate = new Date(renewalDateText);
const startDate = new Date(renewalDate);
startDate.setMonth(startDate.getMonth() - 1);
const today = new Date();
today.setHours(0, 0, 0, 0);
const totalDaysInPeriod = (renewalDate - startDate) / (1000 * 60 * 60 * 24);
const daysPassed = Math.max(0, (today - startDate) / (1000 * 60 * 60 * 24));
const daysLeft = Math.max(0, Math.ceil((renewalDate - today) / (1000 * 60 * 60 * 24)));
const usagePercent = (usedCost / totalBudget) * 100;
const timePercent = (daysPassed / totalDaysInPeriod) * 100;
const pacingDifference = usagePercent - timePercent;
let pacingStatus, pacingColor;
if (pacingDifference > PACING_ORANGE_LIMIT) {
pacingStatus = 'High Usage';
pacingColor = 'var(--danger)';
} else if (pacingDifference > PACING_GREEN_LIMIT) {
pacingStatus = 'Over Budget';
pacingColor = 'var(--warning)';
} else {
pacingStatus = 'On Track';
pacingColor = 'var(--success)';
}
const renewalDateFormatted = renewalDate.toISOString().split('T')[0];
return { usedCost, totalBudget, usagePercent, timePercent, pacingStatus, pacingColor, daysLeft, renewalDateFormatted };
}
/**
* Renders the full, expanded widget view.
*/
function renderExpandedView(container, metrics) {
container.className = 'expanded';
const displayUsagePercent = Math.min(100, metrics.usagePercent);
const displayTimePercent = Math.min(100, metrics.timePercent);
container.innerHTML = `
<div class="kagi-widget-control-btn" title="Collapse Widget">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; padding-right: 1.5rem; font-size: 1em;">
<strong>AI Budget</strong>
<strong style="color: ${metrics.pacingColor};">${metrics.pacingStatus}</strong>
</div>
<div style="display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.25rem;">
<span>${metrics.usagePercent.toFixed(1)}% Used</span>
<div title="Renews on ${metrics.renewalDateFormatted}" style="cursor: help; color: var(--primary-600); font-size: 0.9em;">${metrics.daysLeft} days left</div>
</div>
<div style="color: var(--primary-700); font-size: 0.9em; margin-bottom: 0.5rem;">($${metrics.usedCost.toFixed(2)} / $${metrics.totalBudget.toFixed(2)})</div>
<div style="position: relative; height: 0.75rem; background-color: var(--primary-200); border-radius: 0.375rem; overflow: hidden;">
<div style="width: ${displayUsagePercent}%; height: 100%; background-color: ${metrics.pacingColor}; border-radius: 0.375rem 0 0 0.375rem; transition: width 0.5s ease;"></div>
<div title="Expected usage: ${metrics.timePercent.toFixed(1)}%" style="position: absolute; top: -0.125rem; bottom: -0.125rem; left: ${displayTimePercent}%; width: 0.125rem; background-color: var(--primary); cursor: help;"></div>
</div>
<div style="display: flex; justify-content: space-between; font-size: 0.85em; color: var(--primary-600); margin-top: 0.25rem; padding: 0 0.0625rem;">
<span>Usage</span>
<span>Pacing Marker</span>
</div>
`;
container.querySelector('.kagi-widget-control-btn').onclick = toggleCollapseState;
}
/**
* Renders the small, collapsed widget view with the pacing color icon.
*/
function renderCollapsedView(container, metrics) {
container.className = 'collapsed';
container.innerHTML = `
<div class="kagi-widget-control-btn" title="Expand Widget">
<svg width="20" height="20" viewBox="0 0 640 640">
<path d="M128 160C110.3 160 96 174.3 96 192L96 448C96 465.7 110.3 480 128 480L512 480C529.7 480 544 465.7 544 448L544 192C544 174.3 529.7 160 512 160L128 160zM64 192C64 156.7 92.7 128 128 128L512 128C547.3 128 576 156.7 576 192L576 448C576 483.3 547.3 512 512 512L128 512C92.7 512 64 483.3 64 448L64 192zM368 352L464 352C472.8 352 480 359.2 480 368C480 376.8 472.8 384 464 384L368 384C359.2 384 352 376.8 352 368C352 359.2 359.2 352 368 352zM352 272C352 263.2 359.2 256 368 256L464 256C472.8 256 480 263.2 480 272C480 280.8 472.8 288 464 288L368 288C359.2 288 352 280.8 352 272zM224 216C232.8 216 240 223.2 240 232L240 240L256 240C264.8 240 272 247.2 272 256C272 264.8 264.8 272 256 272L206.3 272C198.4 272 192 278.4 192 286.3C192 293.3 197.1 299.3 204 300.4L249.3 308C271.6 311.7 288 331 288 353.7C288 379.3 267.3 400 241.7 400L240 400L240 408C240 416.8 232.8 424 224 424C215.2 424 208 416.8 208 408L208 400L184 400C175.2 400 168 392.8 168 384C168 375.2 175.2 368 184 368L241.7 368C249.6 368 256 361.6 256 353.7C256 346.7 250.9 340.7 244 339.6L198.7 332C176.4 328.3 160 309 160 286.3C160 260.7 180.7 240 206.3 240L208 240L208 232C208 223.2 215.2 216 224 216z"/>
</svg>
</div>
`;
const btn = container.querySelector('.kagi-widget-control-btn');
const svg = btn.querySelector('svg');
if (metrics && metrics.pacingColor) {
svg.style.fill = metrics.pacingColor; // Directly set the SVG fill color
}
btn.onclick = toggleCollapseState;
}
/**
* Toggles the collapsed state and re-renders instantly from cache.
*/
async function toggleCollapseState() {
const isCurrentlyCollapsed = await GM_getValue(STORAGE_KEY_COLLAPSED, false);
await GM_setValue(STORAGE_KEY_COLLAPSED, !isCurrentlyCollapsed);
await renderWidget();
}
/**
* Renders the widget based on the current state and cached data.
*/
async function renderWidget() {
let container = document.getElementById(CONTAINER_ID);
if (!container) {
container = document.createElement('div');
container.id = CONTAINER_ID;
document.body.appendChild(container);
}
const isCollapsed = await GM_getValue(STORAGE_KEY_COLLAPSED, false);
try {
if (!lastMetrics) { // Data not loaded yet
if (isCollapsed) renderCollapsedView(container, null);
return;
}
if (isCollapsed) {
renderCollapsedView(container, lastMetrics);
} else {
renderExpandedView(container, lastMetrics);
}
container.dataset.lastRefresh = new Date().toISOString();
} catch (error) {
console.error("Kagi Usage Tracker Error:", error);
container.className = 'expanded';
container.innerHTML = `<strong style="color: var(--danger);">Tracker Error</strong><div style="font-size: 0.75rem; color: var(--primary-700);">${error}</div>`;
}
}
/**
* Fetches new data, updates the cache, and triggers a re-render.
*/
async function fetchAndUpdate() {
try {
const rawData = await fetchBillingData();
lastMetrics = calculateMetrics(rawData);
await renderWidget();
} catch (error) {
lastMetrics = null; // Invalidate cache on error
await renderWidget(); // Render the error state
console.error("Kagi Usage Tracker refresh failed:", error);
}
}
/**
* Sets up all event listeners using a robust, delegated approach.
*/
function initializeInteractivity() {
let typingTimeout = null;
document.body.addEventListener('input', (event) => {
if (event.target.id !== 'promptBox') return;
const widget = document.getElementById(CONTAINER_ID);
if (!widget || !widget.classList.contains('expanded')) return;
clearTimeout(typingTimeout);
if (event.target.value.trim() === '') {
widget.classList.remove('kagi-widget-hidden');
} else {
widget.classList.add('kagi-widget-hidden');
typingTimeout = setTimeout(() => widget.classList.remove('kagi-widget-hidden'), INACTIVITY_DELAY);
}
});
document.body.addEventListener('submit', (event) => {
if (event.target.closest('form')?.querySelector('#promptBox')) {
const widget = document.getElementById(CONTAINER_ID);
if (widget) {
clearTimeout(typingTimeout);
widget.classList.remove('kagi-widget-hidden');
}
}
});
}
/**
* Main function to start the script.
*/
async function main() {
injectCss();
initializeInteractivity();
await fetchAndUpdate(); // Initial fetch and render
setInterval(async () => {
if (!document.hidden) {
await fetchAndUpdate();
}
}, REFRESH_INTERVAL);
}
main();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment