AI Usage

Show pace dividers on AI usage pages (Codex, Claude, Kimi Code)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         AI Usage
// @namespace    https://github.com/a322655
// @version      1.0.1
// @author       a322655
// @description  Show pace dividers on AI usage pages (Codex, Claude, Kimi Code)
// @license      MIT
// @homepageURL  https://github.com/a322655/ai-usage-userscript
// @supportURL   https://github.com/a322655/ai-usage-userscript/issues
// @match        https://chatgpt.com/codex/cloud/settings/analytics*
// @match        https://claude.ai/settings/usage*
// @match        https://www.kimi.com/code/console*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
var interceptedData = null;
	var USAGE_API_PATH = "/backend-api/wham/usage";
	var isUsageApiUrl = (url) => url.includes(USAGE_API_PATH) === true && url.includes("daily") === false && url.includes("credit") === false;
	var extractUrlFromInput = (input) => {
		if (typeof input === "string") return input;
		if (input instanceof Request) return input.url;
		return "";
	};
	var handleInterceptedResponse = (response) => {
		response.clone().json().then((data) => {
			interceptedData = data;
		}).catch(() => void 0);
	};
	var installFetchInterceptor = () => {
		const originalFetch = globalThis.fetch;
		globalThis.fetch = new Proxy(originalFetch, { apply: (target, thisArg, args) => {
			const result = Reflect.apply(target, thisArg, args);
			if (isUsageApiUrl(extractUrlFromInput(args[0])) === true) result.then(handleInterceptedResponse).catch(() => void 0);
			return result;
		} });
	};
	if (globalThis.location.hostname === "chatgpt.com") installFetchInterceptor();
	var toWindow = (apiWindow) => {
		if (apiWindow === null || apiWindow === void 0) return null;
		if (apiWindow.limit_window_seconds <= 0 || apiWindow.reset_at <= 0) return null;
		return {
			durationMs: apiWindow.limit_window_seconds * 1e3,
			resetAt: new Date(apiWindow.reset_at * 1e3)
		};
	};
	var resolveRateLimitWindow = (rateLimit, headerText) => {
		if (/weekly/i.test(headerText) === true) return toWindow(rateLimit.secondary_window);
		if (/\d+\s*hour/i.test(headerText) === true) return toWindow(rateLimit.primary_window);
		return null;
	};
	var findAdditionalModelWindow = (additionalLimits, headerText) => {
		for (const model of additionalLimits) if (headerText.includes(model.limit_name) === true) return resolveRateLimitWindow(model.rate_limit, headerText);
		return null;
	};
	var findCodexRateLimitWindow = (headerText) => {
		if (interceptedData === null) return null;
		if (interceptedData.additional_rate_limits !== void 0) return findAdditionalModelWindow(interceptedData.additional_rate_limits, headerText);
		if (/code\s*review/i.test(headerText) === true) return toWindow(interceptedData.code_review_rate_limit?.primary_window ?? null);
		return resolveRateLimitWindow(interceptedData.rate_limit ?? {
			primary_window: null,
			secondary_window: null
		}, headerText);
	};
	var DAY_ABBR_TO_INDEX = {
		sun: 0,
		mon: 1,
		tue: 2,
		wed: 3,
		thu: 4,
		fri: 5,
		sat: 6
	};
	var parseTimeTokens = (hourToken, minuteToken, meridiemToken) => {
		const hourValue = Number.parseInt(hourToken, 10);
		const minuteValue = Number.parseInt(minuteToken, 10);
		if (Number.isNaN(hourValue) === true || Number.isNaN(minuteValue) === true || hourValue < 1 || hourValue > 12 || minuteValue < 0 || minuteValue > 59) return null;
		let normalizedHours = hourValue % 12;
		if (meridiemToken.toUpperCase() === "PM") normalizedHours += 12;
		return normalizedHours * 60 + minuteValue;
	};
	var buildDateAtTimeOfDay = (totalMinutes, now) => {
		const candidateDate = new Date(now.getTime());
		candidateDate.setHours(Math.floor(totalMinutes / 60), totalMinutes % 60, 0, 0);
		return candidateDate;
	};
	var parseDayTimeLabel = (resetLabel, now) => {
		const dayTimeMatch = resetLabel.match(/^\s*(?<day>Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*\s+(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<meridiem>[AP]M)\s*$/i);
		if (dayTimeMatch?.groups === void 0) return null;
		const totalMinutes = parseTimeTokens(dayTimeMatch.groups["hour"] ?? "", dayTimeMatch.groups["minute"] ?? "", dayTimeMatch.groups["meridiem"] ?? "");
		if (totalMinutes === null) return null;
		const targetDayIndex = DAY_ABBR_TO_INDEX[(dayTimeMatch.groups["day"] ?? "").toLowerCase().slice(0, 3)];
		if (targetDayIndex === void 0) return null;
		const candidateDate = buildDateAtTimeOfDay(totalMinutes, now);
		let daysToAdd = targetDayIndex - candidateDate.getDay();
		if (daysToAdd < 0) daysToAdd += 7;
		if (daysToAdd === 0 && candidateDate.getTime() <= now.getTime()) daysToAdd = 7;
		candidateDate.setDate(candidateDate.getDate() + daysToAdd);
		return candidateDate;
	};
	var parseTimeOnlyLabel = (resetLabel, now) => {
		const timeMatch = resetLabel.match(/^\s*(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<meridiem>[AP]M)\s*$/i);
		if (timeMatch?.groups === void 0) return null;
		const totalMinutes = parseTimeTokens(timeMatch.groups["hour"] ?? "", timeMatch.groups["minute"] ?? "", timeMatch.groups["meridiem"] ?? "");
		if (totalMinutes === null) return null;
		const candidateDate = buildDateAtTimeOfDay(totalMinutes, now);
		if (candidateDate.getTime() <= now.getTime()) candidateDate.setDate(candidateDate.getDate() + 1);
		return candidateDate;
	};
	var parseRelativeTimeLabel = (resetLabel, now) => {
		const relativeMatch = resetLabel.match(/^in\s+(?:(\d+)\s+days?\s*)?(?:(\d+)\s+hours?\s*)?(?:(\d+)\s+minutes?)?\s*$/i);
		if (relativeMatch === null) return null;
		const days = Number.parseInt(relativeMatch[1] ?? "0", 10) || 0;
		const hours = Number.parseInt(relativeMatch[2] ?? "0", 10) || 0;
		const minutes = Number.parseInt(relativeMatch[3] ?? "0", 10) || 0;
		const totalMs = (days * 24 * 60 + hours * 60 + minutes) * 60 * 1e3;
		if (totalMs <= 0) return null;
		return new Date(now.getTime() + totalMs);
	};
	var parseResetDate = (resetLabel, now) => {
		const directTimestamp = Date.parse(resetLabel);
		if (Number.isNaN(directTimestamp) === false) return new Date(directTimestamp);
		return parseDayTimeLabel(resetLabel, now) ?? parseTimeOnlyLabel(resetLabel, now) ?? parseRelativeTimeLabel(resetLabel, now);
	};
	var clamp = (value, min, max) => {
		if (value < min) return min;
		if (value > max) return max;
		return value;
	};
	var normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
	var ONE_WEEK_MS = 10080 * 60 * 1e3;
	var CODEX_TRACK_SELECTOR = "div[class*=\"bg-[#ebebf0]\"]";
	var CODEX_FILL_SELECTOR = "div[class*=\"bg-[#22c55e]\"]";
	var CLAUDE_TRACK_SELECTOR = "div[class*=\"bg-bg-000\"][class*=\"h-4\"][class*=\"rounded\"]";
	var CLAUDE_FILL_SELECTOR = "div[class*=\"h-full\"]";
	var KIMI_CARD_SELECTOR = ".stats-card";
	var KIMI_BAR_SELECTOR = ".stats-card-progress-bar";
	var KIMI_FILL_SELECTOR = ".stats-card-progress-filled";
	var validateTrackGeometry = (trackRect, fillRect) => {
		if (trackRect.width < 120 || trackRect.height < 6 || trackRect.height > 18) return false;
		if (fillRect.height < 4 || fillRect.height > 18) return false;
		if (Math.abs(fillRect.top - trackRect.top) > 2) return false;
		if (fillRect.width < 0 || fillRect.width > trackRect.width + 1) return false;
		return true;
	};
	var inferDurationMs = (text, resetLabel) => {
		if (/weekly/i.test(text) === true || /code\s*review/i.test(text) === true) return ONE_WEEK_MS;
		if (/\brate\s+limit\b/i.test(text) === true) return null;
		if (resetLabel !== null) {
			const hoursMatch = resetLabel.match(/\bin\s+(\d+)\s+hours?\b/i);
			if (hoursMatch !== null) {
				const hours = Number.parseInt(hoursMatch[1] ?? "0", 10);
				if (Number.isNaN(hours) === false && hours >= 24) return ONE_WEEK_MS;
			}
		}
		if (resetLabel !== null && /\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\w*/i.test(resetLabel) === true) return ONE_WEEK_MS;
		return null;
	};
	var extractResetLabel = (text) => {
		const label = text.match(/Resets\s+(.+)$/i)?.[1]?.trim();
		if (label === void 0 || label.length === 0) return null;
		return label;
	};
	var findResetLabel = (containerElement, fullText) => {
		const candidateNodes = containerElement.querySelectorAll("p, span, div");
		for (const candidateNode of candidateNodes) {
			if (candidateNode instanceof HTMLElement === false) continue;
			const label = extractResetLabel(normalizeWhitespace(candidateNode.textContent ?? ""));
			if (label !== null) return label;
		}
		return extractResetLabel(fullText);
	};
	var parseResetInfo = (containerElement, fullText, durationSourceText, now) => {
		const resetLabel = findResetLabel(containerElement, fullText);
		return {
			resetAt: resetLabel === null ? null : parseResetDate(resetLabel, now),
			durationMs: inferDurationMs(durationSourceText, resetLabel)
		};
	};
	var resolveCodexProgressElements = (articleElement) => {
		const trackNode = articleElement.querySelector(CODEX_TRACK_SELECTOR);
		const fillNode = articleElement.querySelector(CODEX_FILL_SELECTOR);
		if (trackNode instanceof HTMLElement === false || fillNode instanceof HTMLElement === false) return null;
		const trackContainerNode = trackNode.parentElement;
		if (trackContainerNode instanceof HTMLElement === false) return null;
		if (validateTrackGeometry(trackNode.getBoundingClientRect(), fillNode.getBoundingClientRect()) === false) return null;
		return {
			trackElement: trackNode,
			fillElement: fillNode,
			trackContainerElement: trackContainerNode
		};
	};
	var collectCodexCards = (now) => {
		const cards = [];
		const articleNodes = document.querySelectorAll("article");
		for (const articleNode of articleNodes) {
			const fullText = normalizeWhitespace(articleNode.textContent ?? "");
			if (/remaining/i.test(fullText) === false) continue;
			const resolved = resolveCodexProgressElements(articleNode);
			if (resolved === null) continue;
			const headerText = normalizeWhitespace(articleNode.querySelector("header")?.textContent ?? "");
			const apiWindow = findCodexRateLimitWindow(headerText);
			if (apiWindow !== null) {
				cards.push({
					fullText,
					...resolved,
					resetAt: apiWindow.resetAt,
					durationMs: apiWindow.durationMs,
					fillMeaning: "remaining"
				});
				continue;
			}
			const { resetAt, durationMs } = parseResetInfo(articleNode, fullText, headerText.length > 0 ? headerText : fullText, now);
			cards.push({
				fullText,
				...resolved,
				resetAt,
				durationMs,
				fillMeaning: "remaining"
			});
		}
		return cards;
	};
	var resolveClaudeProgressElements = (candidateNode) => {
		const fillNode = candidateNode.querySelector(CLAUDE_FILL_SELECTOR);
		if (fillNode instanceof HTMLElement === false) return null;
		if (validateTrackGeometry(candidateNode.getBoundingClientRect(), fillNode.getBoundingClientRect()) === false) return null;
		const trackContainerNode = candidateNode.parentElement;
		if (trackContainerNode instanceof HTMLElement === false) return null;
		const rowNode = trackContainerNode.parentElement?.parentElement ?? null;
		if (rowNode instanceof HTMLElement === false) return null;
		return {
			trackElement: candidateNode,
			fillElement: fillNode,
			trackContainerElement: trackContainerNode,
			rowElement: rowNode
		};
	};
	var CLAUDE_SKIP_PATTERNS = [/current\s+session/i, /\$[\d,.]+\s+spent/i];
	var collectClaudeCards = (now) => {
		const cards = [];
		const trackCandidates = document.querySelectorAll(CLAUDE_TRACK_SELECTOR);
		for (const candidateNode of trackCandidates) {
			if (candidateNode instanceof HTMLElement === false) continue;
			const resolved = resolveClaudeProgressElements(candidateNode);
			if (resolved === null) continue;
			const rowText = normalizeWhitespace(resolved.rowElement.textContent ?? "");
			if (CLAUDE_SKIP_PATTERNS.some((pattern) => pattern.test(rowText)) === true) continue;
			const { resetAt, durationMs } = parseResetInfo(resolved.rowElement, rowText, rowText, now);
			cards.push({
				fullText: rowText,
				trackElement: resolved.trackElement,
				fillElement: resolved.fillElement,
				trackContainerElement: resolved.trackContainerElement,
				resetAt,
				durationMs,
				fillMeaning: "used"
			});
		}
		return cards;
	};
	var collectKimiCards = (now) => {
		const cards = [];
		const cardNodes = document.querySelectorAll(KIMI_CARD_SELECTOR);
		for (const cardNode of cardNodes) {
			if (cardNode instanceof HTMLElement === false) continue;
			const barNode = cardNode.querySelector(KIMI_BAR_SELECTOR);
			const fillNode = cardNode.querySelector(KIMI_FILL_SELECTOR);
			if (barNode instanceof HTMLElement === false || fillNode instanceof HTMLElement === false) continue;
			if (validateTrackGeometry(barNode.getBoundingClientRect(), fillNode.getBoundingClientRect()) === false) continue;
			const fullText = normalizeWhitespace(cardNode.textContent ?? "");
			const { resetAt, durationMs } = parseResetInfo(cardNode, fullText, fullText, now);
			cards.push({
				fullText,
				trackElement: barNode,
				fillElement: fillNode,
				trackContainerElement: barNode,
				resetAt,
				durationMs,
				fillMeaning: "used"
			});
		}
		return cards;
	};
	var buildResetByDurationLookup = (cards) => {
		const lookup = new Map();
		for (const card of cards) {
			if (card.durationMs === null || card.resetAt === null) continue;
			if (lookup.has(card.durationMs) === false) lookup.set(card.durationMs, card.resetAt);
		}
		return lookup;
	};
	var findWeeklyReset = (cards) => {
		for (const card of cards) if (/weekly/i.test(card.fullText) === true && card.resetAt !== null) return card.resetAt;
		return null;
	};
	var resolveMissingResetInformation = (cards) => {
		const resetByDurationLookup = buildResetByDurationLookup(cards);
		const weeklyReset = findWeeklyReset(cards);
		for (const card of cards) {
			if (card.resetAt !== null) continue;
			if (card.durationMs !== null) {
				const fallbackReset = resetByDurationLookup.get(card.durationMs);
				if (fallbackReset !== void 0) {
					card.resetAt = fallbackReset;
					continue;
				}
			}
			if (/code review/i.test(card.fullText) === true && weeklyReset !== null) {
				card.durationMs = ONE_WEEK_MS;
				card.resetAt = weeklyReset;
			}
		}
	};
	var collectUsageCards = (now) => {
		const hostname = globalThis.location.hostname;
		if (hostname === "claude.ai") return collectClaudeCards(now);
		if (hostname === "www.kimi.com") return collectKimiCards(now);
		return collectCodexCards(now);
	};
	var DIVIDER_CLASS = "ai-usage-pace-divider";
	var DIVIDER_BAR_CLASS = "ai-usage-pace-divider-bar";
	var UPDATE_INTERVAL_MS = 3e4;
	var DIVIDER_COLOR = "rgb(249, 115, 22)";
	var DIVIDER_HIT_AREA_WIDTH = "12px";
	var DIVIDER_BAR_WIDTH = "2px";
	var computeTargetRemainingRatio = (card, now) => {
		if (card.resetAt === null || card.durationMs === null || card.durationMs <= 0) return null;
		const resetTimeMs = card.resetAt.getTime();
		if (Number.isFinite(resetTimeMs) === false) return null;
		const cycleStartMs = resetTimeMs - card.durationMs;
		return clamp(1 - clamp(now.getTime() - cycleStartMs, 0, card.durationMs) / card.durationMs, 0, 1);
	};
	var computeDividerLeftPercent = (card, targetRemainingRatio) => {
		if (card.fillMeaning === "used") return (1 - targetRemainingRatio) * 100;
		return targetRemainingRatio * 100;
	};
	var ensureDividerElement = (trackContainer) => {
		const existingDivider = trackContainer.querySelector(`.${DIVIDER_CLASS}`);
		if (existingDivider !== null) return existingDivider;
		const dividerElement = document.createElement("div");
		dividerElement.className = DIVIDER_CLASS;
		trackContainer.append(dividerElement);
		return dividerElement;
	};
	var ensureBarElement = (dividerElement) => {
		const existingBar = dividerElement.querySelector(`.${DIVIDER_BAR_CLASS}`);
		if (existingBar !== null) return existingBar;
		const barElement = document.createElement("div");
		barElement.className = DIVIDER_BAR_CLASS;
		dividerElement.append(barElement);
		return barElement;
	};
	var removeDividerElement = (trackContainer) => {
		const dividerElement = trackContainer.querySelector(`.${DIVIDER_CLASS}`);
		if (dividerElement !== null) dividerElement.remove();
	};
	var buildDividerTooltip = (targetRemainingRatio) => {
		return `Pace marker: expected ${(targetRemainingRatio * 100).toFixed(1)}% remaining`;
	};
	var applyDividerStyles = (dividerElement, leftPercent) => {
		dividerElement.style.position = "absolute";
		dividerElement.style.top = "-2px";
		dividerElement.style.bottom = "-2px";
		dividerElement.style.left = `${leftPercent.toFixed(4)}%`;
		dividerElement.style.width = DIVIDER_HIT_AREA_WIDTH;
		dividerElement.style.transform = "translateX(-50%)";
		dividerElement.style.backgroundColor = "transparent";
		dividerElement.style.cursor = "help";
		dividerElement.style.zIndex = "5";
	};
	var applyBarStyles = (barElement) => {
		barElement.style.position = "absolute";
		barElement.style.top = "0";
		barElement.style.bottom = "0";
		barElement.style.left = "50%";
		barElement.style.width = DIVIDER_BAR_WIDTH;
		barElement.style.transform = "translateX(-50%)";
		barElement.style.borderRadius = "9999px";
		barElement.style.pointerEvents = "none";
		barElement.style.backgroundColor = DIVIDER_COLOR;
		barElement.style.boxShadow = "0 0 0 1px rgba(255, 255, 255, 0.7)";
	};
	var updateDividerElement = (card, targetRemainingRatio) => {
		const trackContainer = card.trackContainerElement;
		if (getComputedStyle(trackContainer).position === "static") trackContainer.style.position = "relative";
		const leftPercent = computeDividerLeftPercent(card, targetRemainingRatio);
		const dividerElement = ensureDividerElement(trackContainer);
		applyDividerStyles(dividerElement, leftPercent);
		applyBarStyles(ensureBarElement(dividerElement));
		dividerElement.title = buildDividerTooltip(targetRemainingRatio);
	};
	var renderPaceDividers = () => {
		const now = new Date();
		const cards = collectUsageCards(now);
		if (globalThis.location.hostname !== "chatgpt.com") resolveMissingResetInformation(cards);
		for (const card of cards) {
			const targetRemainingRatio = computeTargetRemainingRatio(card, now);
			if (targetRemainingRatio === null) {
				removeDividerElement(card.trackContainerElement);
				continue;
			}
			updateDividerElement(card, targetRemainingRatio);
		}
	};
	var renderScheduled = false;
	var scheduleRender = () => {
		if (renderScheduled === true) return;
		renderScheduled = true;
		globalThis.requestAnimationFrame(() => {
			renderScheduled = false;
			renderPaceDividers();
		});
	};
	var setupAutoRefresh = () => {
		new MutationObserver(scheduleRender).observe(document.body, {
			childList: true,
			subtree: true
		});
		globalThis.setInterval(scheduleRender, UPDATE_INTERVAL_MS);
		globalThis.addEventListener("resize", scheduleRender);
		document.addEventListener("visibilitychange", () => {
			if (document.visibilityState === "visible") scheduleRender();
		});
	};
	var bootstrap = () => {
		const globalWindow = globalThis;
		if (globalWindow.__aiUsageDividerInitialized__ === true) return;
		globalWindow.__aiUsageDividerInitialized__ = true;
		const init = () => {
			scheduleRender();
			globalThis.setTimeout(() => {
				scheduleRender();
			}, 300);
			globalThis.setTimeout(() => {
				scheduleRender();
			}, 2e3);
			setupAutoRefresh();
		};
		if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
		else init();
	};
	bootstrap();
})();