AI Usage

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         AI Usage
// @namespace    https://github.com/a322655
// @version      1.0.0
// @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/settings/usage*
// @match        https://claude.ai/settings/usage*
// @match        https://www.kimi.com/code/console*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  let interceptedData = null;
  const USAGE_API_PATH = "/backend-api/wham/usage";
  const isUsageApiUrl = (url) => url.includes(USAGE_API_PATH) === true && url.includes("daily") === false && url.includes("credit") === false;
  const extractUrlFromInput = (input) => {
    if (typeof input === "string") {
      return input;
    }
    if (input instanceof Request) {
      return input.url;
    }
    return "";
  };
  const handleInterceptedResponse = (response) => {
    response.clone().json().then((data) => {
      interceptedData = data;
    }).catch(() => void 0);
  };
  const installFetchInterceptor = () => {
    const originalFetch = globalThis.fetch;
    const handler = {
      apply: (target, thisArg, args) => {
        const result = Reflect.apply(
          target,
          thisArg,
          args
        );
        const url = extractUrlFromInput(args[0]);
        if (isUsageApiUrl(url) === true) {
          result.then(handleInterceptedResponse).catch(
() => void 0
          );
        }
        return result;
      }
    };
    globalThis.fetch = new Proxy(originalFetch, handler);
  };
  if (globalThis.location.hostname === "chatgpt.com") {
    installFetchInterceptor();
  }
  const 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)
    };
  };
  const 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;
  };
  const findAdditionalModelWindow = (additionalLimits, headerText) => {
    for (const model of additionalLimits) {
      if (headerText.includes(model.limit_name) === true) {
        return resolveRateLimitWindow(model.rate_limit, headerText);
      }
    }
    return null;
  };
  const 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
      );
    }
    const fallbackRateLimit = {
      primary_window: null,
      secondary_window: null
    };
    return resolveRateLimitWindow(
      interceptedData.rate_limit ?? fallbackRateLimit,
      headerText
    );
  };
  const DAY_ABBR_TO_INDEX = {
    sun: 0,
    mon: 1,
    tue: 2,
    wed: 3,
    thu: 4,
    fri: 5,
    sat: 6
  };
  const 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;
  };
  const buildDateAtTimeOfDay = (totalMinutes, now) => {
    const candidateDate = new Date(now.getTime());
    candidateDate.setHours(
      Math.floor(totalMinutes / 60),
      totalMinutes % 60,
      0,
      0
    );
    return candidateDate;
  };
  const 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 dayAbbr = (dayTimeMatch.groups["day"] ?? "").toLowerCase().slice(0, 3);
    const targetDayIndex = DAY_ABBR_TO_INDEX[dayAbbr];
    if (targetDayIndex === void 0) {
      return null;
    }
    const candidateDate = buildDateAtTimeOfDay(totalMinutes, now);
    const currentDayIndex = candidateDate.getDay();
    let daysToAdd = targetDayIndex - currentDayIndex;
    if (daysToAdd < 0) {
      daysToAdd += 7;
    }
    if (daysToAdd === 0 && candidateDate.getTime() <= now.getTime()) {
      daysToAdd = 7;
    }
    candidateDate.setDate(candidateDate.getDate() + daysToAdd);
    return candidateDate;
  };
  const 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;
  };
  const 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);
  };
  const 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);
  };
  const clamp = (value, min, max) => {
    if (value < min) {
      return min;
    }
    if (value > max) {
      return max;
    }
    return value;
  };
  const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
  const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1e3;
  const CODEX_TRACK_SELECTOR = 'div[class*="bg-[#ebebf0]"]';
  const CODEX_FILL_SELECTOR = 'div[class*="bg-[#22c55e]"]';
  const CLAUDE_TRACK_SELECTOR = 'div[class*="bg-bg-000"][class*="h-4"][class*="rounded"]';
  const CLAUDE_FILL_SELECTOR = 'div[class*="h-full"]';
  const KIMI_CARD_SELECTOR = ".stats-card";
  const KIMI_BAR_SELECTOR = ".stats-card-progress-bar";
  const KIMI_FILL_SELECTOR = ".stats-card-progress-filled";
  const 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;
  };
  const 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;
  };
  const extractResetLabel = (text) => {
    const label = text.match(/Resets\s+(.+)$/i)?.[1]?.trim();
    if (label === void 0 || label.length === 0) {
      return null;
    }
    return label;
  };
  const 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);
  };
  const parseResetInfo = (containerElement, fullText, durationSourceText, now) => {
    const resetLabel = findResetLabel(containerElement, fullText);
    const resetAt = resetLabel === null ? null : parseResetDate(resetLabel, now);
    const durationMs = inferDurationMs(
      durationSourceText,
      resetLabel
    );
    return {
      resetAt,
      durationMs
    };
  };
  const 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;
    }
    const trackRect = trackNode.getBoundingClientRect();
    const fillRect = fillNode.getBoundingClientRect();
    if (validateTrackGeometry(trackRect, fillRect) === false) {
      return null;
    }
    return {
      trackElement: trackNode,
      fillElement: fillNode,
      trackContainerElement: trackContainerNode
    };
  };
  const 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 headerElement = articleNode.querySelector("header");
      const headerText = normalizeWhitespace(
        headerElement?.textContent ?? ""
      );
      const apiWindow = findCodexRateLimitWindow(headerText);
      if (apiWindow !== null) {
        cards.push({
          fullText,
          ...resolved,
          resetAt: apiWindow.resetAt,
          durationMs: apiWindow.durationMs,
          fillMeaning: "remaining"
        });
        continue;
      }
      const durationSourceText = headerText.length > 0 ? headerText : fullText;
      const { resetAt, durationMs } = parseResetInfo(
        articleNode,
        fullText,
        durationSourceText,
        now
      );
      cards.push({
        fullText,
        ...resolved,
        resetAt,
        durationMs,
        fillMeaning: "remaining"
      });
    }
    return cards;
  };
  const resolveClaudeProgressElements = (candidateNode) => {
    const fillNode = candidateNode.querySelector(CLAUDE_FILL_SELECTOR);
    if (fillNode instanceof HTMLElement === false) {
      return null;
    }
    const trackRect = candidateNode.getBoundingClientRect();
    const fillRect = fillNode.getBoundingClientRect();
    if (validateTrackGeometry(trackRect, fillRect) === 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
    };
  };
  const CLAUDE_SKIP_PATTERNS = [
    /current\s+session/i,
    /\$[\d,.]+\s+spent/i
  ];
  const 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 ?? ""
      );
      const shouldSkip = CLAUDE_SKIP_PATTERNS.some(
        (pattern) => pattern.test(rowText)
      );
      if (shouldSkip === 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;
  };
  const 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;
      }
      const trackRect = barNode.getBoundingClientRect();
      const fillRect = fillNode.getBoundingClientRect();
      if (validateTrackGeometry(trackRect, fillRect) === 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;
  };
  const 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;
  };
  const findWeeklyReset = (cards) => {
    for (const card of cards) {
      if (/weekly/i.test(card.fullText) === true && card.resetAt !== null) {
        return card.resetAt;
      }
    }
    return null;
  };
  const 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;
      }
    }
  };
  const 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);
  };
  const DIVIDER_CLASS = "ai-usage-pace-divider";
  const UPDATE_INTERVAL_MS = 3e4;
  const DIVIDER_COLOR = "rgb(249, 115, 22)";
  const 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;
    const elapsedMs = clamp(
      now.getTime() - cycleStartMs,
      0,
      card.durationMs
    );
    const targetRemainingRatio = 1 - elapsedMs / card.durationMs;
    return clamp(targetRemainingRatio, 0, 1);
  };
  const computeDividerLeftPercent = (card, targetRemainingRatio) => {
    if (card.fillMeaning === "used") {
      return (1 - targetRemainingRatio) * 100;
    }
    return targetRemainingRatio * 100;
  };
  const 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;
  };
  const removeDividerElement = (trackContainer) => {
    const dividerElement = trackContainer.querySelector(
      `.${DIVIDER_CLASS}`
    );
    if (dividerElement !== null) {
      dividerElement.remove();
    }
  };
  const buildDividerTooltip = (targetRemainingRatio) => {
    const targetPercent = (targetRemainingRatio * 100).toFixed(1);
    return `Pace marker: expected ${targetPercent}% remaining`;
  };
  const applyDividerStyles = (dividerElement, leftPercent) => {
    dividerElement.style.position = "absolute";
    dividerElement.style.top = "-2px";
    dividerElement.style.bottom = "-2px";
    dividerElement.style.left = `${leftPercent.toFixed(4)}%`;
    dividerElement.style.width = "2px";
    dividerElement.style.transform = "translateX(-50%)";
    dividerElement.style.borderRadius = "9999px";
    dividerElement.style.pointerEvents = "none";
    dividerElement.style.zIndex = "5";
    dividerElement.style.backgroundColor = DIVIDER_COLOR;
    dividerElement.style.boxShadow = "0 0 0 1px rgba(255, 255, 255, 0.7)";
  };
  const 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);
    dividerElement.title = buildDividerTooltip(targetRemainingRatio);
  };
  const 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);
    }
  };
  let renderScheduled = false;
  const scheduleRender = () => {
    if (renderScheduled === true) {
      return;
    }
    renderScheduled = true;
    globalThis.requestAnimationFrame(() => {
      renderScheduled = false;
      renderPaceDividers();
    });
  };
  const setupAutoRefresh = () => {
    const observer = new MutationObserver(scheduleRender);
    observer.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();
      }
    });
  };
  const 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();

})();